@aksp/opencrew 1.10.0 → 1.11.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.
- package/CHANGELOG.md +54 -0
- package/README.md +11 -3
- package/package.json +1 -1
- package/src/commands/init.js +4 -5
- package/src/commands/update.js +8 -0
- package/src/lib/resumo.js +5 -1
- package/templates/AGENTS.md +8 -6
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/architect.agent.yaml +25 -16
- package/templates/_opencrew/core/formato-da-crew.md +162 -0
- package/templates/_opencrew/core/prompts/build.prompt.md +33 -57
- package/templates/_opencrew/core/prompts/design.prompt.md +11 -11
- package/templates/_opencrew/core/prompts/discovery.prompt.md +23 -5
- package/templates/_opencrew/core/prompts/repair.prompt.md +75 -84
- package/templates/_opencrew/core/runner.pipeline.md +9 -12
- package/templates/_opencrew/core/scripts/conserto/achados.mjs +158 -0
- package/templates/_opencrew/core/scripts/conserto/aplicar.mjs +156 -0
- package/templates/_opencrew/core/scripts/conserto/argumentos.mjs +51 -0
- package/templates/_opencrew/core/scripts/conserto/crew.mjs +126 -0
- package/templates/_opencrew/core/scripts/conserto/edicoes.mjs +133 -0
- package/templates/_opencrew/core/scripts/conserto/gravar.mjs +55 -0
- package/templates/_opencrew/core/scripts/conserto.mjs +82 -0
- package/templates/_opencrew/core/scripts/verificar/proibicoes.mjs +30 -5
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// `--aplicar`: valida todos os itens, monta as mudanças em memória e só então grava — cada arquivo
|
|
2
|
+
// alterado ganha antes uma cópia `.bak` (a que já existe fica). Um item recusado: nada é gravado.
|
|
3
|
+
// Spec: fase-u4a-conserto-de-crews.md, regras 1 a 4 e 7 (repositório do OpenCrew).
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { dentroDoProjeto } from '../comum.mjs';
|
|
7
|
+
import { lerFrontmatter, semAcento, semBom } from '../verificar/leitura.mjs';
|
|
8
|
+
import { ehRevisaoHumana, itensDeProibicao, lerLinha } from '../verificar/proibicoes.mjs';
|
|
9
|
+
import { comChave, comFonte, comSufixo, comTitulo, montarCsv } from './edicoes.mjs';
|
|
10
|
+
import { erroAntesDeGravar } from './gravar.mjs';
|
|
11
|
+
|
|
12
|
+
const MSG = {
|
|
13
|
+
semValor: (item) => `Sobra ou falta valor em --aplicar "${item.escrito}". Veja os itens com --ajuda.`,
|
|
14
|
+
semPasso: (item) => `Passo não encontrado em --aplicar "${item.escrito}".`,
|
|
15
|
+
checkpoint: (n, item) => `O passo ${n} é um ponto de aprovação: --aplicar "${item.escrito}" não vale para ele.`,
|
|
16
|
+
semFrontmatter: (n) => `Não altero o passo ${n}: o arquivo não tem o bloco de campos no começo (entre linhas ---), ou o campo ocupa mais de uma linha. Ajuste pela edição da crew.`,
|
|
17
|
+
fontesNaLinha: 'Não altero o `crew.yaml`: a lista `fontes:` está escrita numa linha só. Ajuste pela edição da crew.',
|
|
18
|
+
trechoIlegivel: 'O verificador não consegue ler esse trecho como trava: use até 200 caracteres, sem crase nem aspas.',
|
|
19
|
+
semFormato: (id) => `Formato "${id}" não encontrado em \`_opencrew/best-practices.local/\` nem em \`_opencrew/core/best-practices/\`.`,
|
|
20
|
+
fonteAbsoluta: (c) => `A fonte tem de ser um caminho relativo à pasta do projeto: ${c}`,
|
|
21
|
+
fonteFora: (c) => `Caminho fora do projeto: ${c}`,
|
|
22
|
+
fonteAusente: (c) => `Não encontrei no projeto: ${c}`,
|
|
23
|
+
semAgentes: 'A crew não tem agentes em `agents/`: não há de onde refazer o crew-party.csv.',
|
|
24
|
+
semAgente: (item) => `Agente não encontrado em --aplicar "${item.escrito}".`,
|
|
25
|
+
nomeCurto: 'O nome do agente tem duas palavras (nome e sobrenome), sem aspas.',
|
|
26
|
+
jaTemNome: (id, nome) => `O agente ${id} já tem nome: ${nome}. Para trocar, use /opencrew edit.`,
|
|
27
|
+
semFrontmatterDoAgente: (id) => `Não altero o agente ${id}: o arquivo não tem o bloco de campos no começo (entre linhas ---), ou o campo name ocupa mais de uma linha.`,
|
|
28
|
+
semItem: (item) => `Item de proibição não encontrado em --aplicar "${item.escrito}".`,
|
|
29
|
+
jaResolvido: (n) => `A proibição ${n} já tem trecho entre aspas ou já está marcada como revisão humana.`,
|
|
30
|
+
comAspas: 'O trecho da trava não pode ter aspas duplas.',
|
|
31
|
+
foraDoItem: (n, trecho) => `O trecho "${trecho}" não aparece na proibição ${n}.`,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** As mudanças em memória: por arquivo, o texto do disco (`antes`) e o texto novo (`depois`). */
|
|
35
|
+
function novoPlano() {
|
|
36
|
+
const arquivos = new Map();
|
|
37
|
+
const plano = {
|
|
38
|
+
arquivos,
|
|
39
|
+
trocas: 0,
|
|
40
|
+
texto: (arquivo, doDisco) => (arquivos.has(arquivo) ? arquivos.get(arquivo).depois : doDisco),
|
|
41
|
+
/** Texto igual ao que já está no plano não conta como troca. Devolve null (não há erro). */
|
|
42
|
+
trocar(arquivo, doDisco, depois) {
|
|
43
|
+
if (depois === plano.texto(arquivo, doDisco)) return null;
|
|
44
|
+
arquivos.set(arquivo, { antes: arquivos.has(arquivo) ? arquivos.get(arquivo).antes : doDisco, depois });
|
|
45
|
+
plano.trocas += 1;
|
|
46
|
+
return null;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
return plano;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function passoDe(crew, item) {
|
|
53
|
+
const passo = /^\d+$/.test(item.alvo) ? crew.passos.find((p) => p.numero === Number(item.alvo)) : null;
|
|
54
|
+
if (!passo?.arquivo) return { erro: MSG.semPasso(item) };
|
|
55
|
+
return passo.checkpoint ? { erro: MSG.checkpoint(passo.numero, item) } : { passo };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const formatoExiste = (raiz, id) => [['best-practices.local'], ['core', 'best-practices']].some((p) => existsSync(path.join(raiz, '_opencrew', ...p, `${id}.md`)));
|
|
59
|
+
|
|
60
|
+
function formato(crew, item, plano) {
|
|
61
|
+
const { passo, erro } = passoDe(crew, item);
|
|
62
|
+
if (erro) return erro;
|
|
63
|
+
if (!/^[a-z0-9-]+$/.test(item.valor) || !formatoExiste(crew.raiz, item.valor)) return MSG.semFormato(item.valor);
|
|
64
|
+
const novo = comChave(plano.texto(passo.arquivo, passo.bruto), 'format', item.valor, 'agent');
|
|
65
|
+
return novo === null ? MSG.semFrontmatter(passo.numero) : plano.trocar(passo.arquivo, passo.bruto, novo);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function irreversivel(crew, item, plano) {
|
|
69
|
+
const { passo, erro } = passoDe(crew, item);
|
|
70
|
+
if (erro) return erro;
|
|
71
|
+
if (item.valor) return MSG.semValor(item);
|
|
72
|
+
const inline = comChave(plano.texto(passo.arquivo, passo.bruto), 'execution', 'inline');
|
|
73
|
+
const marcado = inline === null ? null : comChave(inline, 'side_effects', 'irreversible');
|
|
74
|
+
return marcado === null ? MSG.semFrontmatter(passo.numero) : plano.trocar(passo.arquivo, passo.bruto, marcado);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ehAbsoluto = (c) => path.isAbsolute(c) || /^[A-Za-z]:/.test(c) || /^[\\/]/.test(c);
|
|
78
|
+
|
|
79
|
+
function fonte(crew, item, plano) {
|
|
80
|
+
if (!item.alvo || !item.valor) return MSG.semValor(item);
|
|
81
|
+
if (ehAbsoluto(item.alvo)) return MSG.fonteAbsoluta(item.alvo);
|
|
82
|
+
if (!dentroDoProjeto(crew.raiz, item.alvo)) return MSG.fonteFora(item.alvo);
|
|
83
|
+
if (!existsSync(path.resolve(crew.raiz, item.alvo))) return MSG.fonteAusente(item.alvo);
|
|
84
|
+
const caminho = item.alvo.replace(/\\/g, '/');
|
|
85
|
+
const novo = comFonte(plano.texto(crew.arquivos.yaml, crew.yaml), caminho, item.valor);
|
|
86
|
+
return novo === null ? MSG.fontesNaLinha : plano.trocar(crew.arquivos.yaml, crew.yaml, novo);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function manifesto(crew, item, plano) {
|
|
90
|
+
if (item.alvo || item.valor) return MSG.semValor(item);
|
|
91
|
+
if (!crew.agentes.length) return MSG.semAgentes;
|
|
92
|
+
const { csv } = crew.arquivos;
|
|
93
|
+
// Os agentes como ficam depois dos itens anteriores desta chamada (um `nome:` antes do `manifesto`).
|
|
94
|
+
const agentes = crew.agentes.map((a) => ({ ...a, dados: lerFrontmatter(semBom(plano.texto(a.arquivo, a.bruto))) ?? {} }));
|
|
95
|
+
return plano.trocar(csv, crew.csv, montarCsv(agentes, plano.texto(csv, crew.csv)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const palavras = (nome) => String(nome ?? '').trim().split(/\s+/).filter(Boolean);
|
|
99
|
+
|
|
100
|
+
/** O nome de duas palavras no `name:` do agente e no título do arquivo (a primeira linha `# …`). */
|
|
101
|
+
function nome(crew, item, plano) {
|
|
102
|
+
const agente = crew.agentes.find((a) => a.id === item.alvo);
|
|
103
|
+
if (!agente) return MSG.semAgente(item);
|
|
104
|
+
if (palavras(item.valor).length < 2 || /["\r\n]/.test(item.valor)) return MSG.nomeCurto;
|
|
105
|
+
if (agente.dados.name === item.valor) return null;
|
|
106
|
+
if (palavras(agente.dados.name).length >= 2) return MSG.jaTemNome(agente.id, agente.dados.name);
|
|
107
|
+
const comNome = comChave(plano.texto(agente.arquivo, agente.bruto), 'name', JSON.stringify(item.valor));
|
|
108
|
+
if (comNome === null) return MSG.semFrontmatterDoAgente(agente.id);
|
|
109
|
+
return plano.trocar(agente.arquivo, agente.bruto, comTitulo(comNome, item.valor));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Por que o item não pode ganhar a marca pedida, ou null. */
|
|
113
|
+
function recusaDaProibicao(alvo, item, humano) {
|
|
114
|
+
if (!alvo.pendente) return MSG.jaResolvido(alvo.n);
|
|
115
|
+
if (humano) return null;
|
|
116
|
+
if (item.valor.includes('"')) return MSG.comAspas;
|
|
117
|
+
return semAcento(alvo.texto).includes(semAcento(item.valor)) ? null : MSG.foraDoItem(alvo.n, item.valor);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function proibicao(crew, item, plano) {
|
|
121
|
+
const { memoria } = crew.arquivos;
|
|
122
|
+
const texto = plano.texto(memoria, crew.memoria) ?? '';
|
|
123
|
+
const alvo = itensDeProibicao(semBom(texto)).find((i) => String(i.n) === item.alvo);
|
|
124
|
+
if (!alvo || !item.valor) return MSG.semItem(item);
|
|
125
|
+
const humano = item.valor === 'revisao-humana';
|
|
126
|
+
const linha = texto.split(/\r?\n/)[alvo.indice];
|
|
127
|
+
if (humano ? ehRevisaoHumana(linha) : linha.includes(`"${item.valor}"`)) return null;
|
|
128
|
+
const recusa = recusaDaProibicao(alvo, item, humano);
|
|
129
|
+
if (recusa) return recusa;
|
|
130
|
+
const novo = comSufixo(texto, alvo.indice, humano ? ' (revisão humana)' : ` — trava: "${item.valor}"`);
|
|
131
|
+
// A trava só vale se o verificador a lê de volta, igual (crase, aspa solta e trecho longo quebram a leitura).
|
|
132
|
+
const lido = lerLinha(novo.split(/\r?\n/)[alvo.indice]);
|
|
133
|
+
if (!humano && !(lido.proibidos.includes(item.valor) && lido.preferidos.length === 0)) return MSG.trechoIlegivel;
|
|
134
|
+
return plano.trocar(memoria, crew.memoria, novo);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ACOES = { manifesto, nome, formato, fonte, proibicao, irreversivel };
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Valida os itens e monta as mudanças, sem gravar.
|
|
141
|
+
* @returns {{ erro: string } | { mudancas: Array<{ arquivo, antes, depois }>, iguais: string[] }}
|
|
142
|
+
* `iguais`: os itens que não mudaram nada (já estava assim)
|
|
143
|
+
*/
|
|
144
|
+
export function planejar(crew, itens) {
|
|
145
|
+
const plano = novoPlano();
|
|
146
|
+
const iguais = [];
|
|
147
|
+
for (const item of itens) {
|
|
148
|
+
const antes = plano.trocas;
|
|
149
|
+
const erro = ACOES[item.tipo](crew, item, plano);
|
|
150
|
+
if (erro) return { erro };
|
|
151
|
+
if (plano.trocas === antes) iguais.push(item.escrito);
|
|
152
|
+
}
|
|
153
|
+
const mudancas = [...plano.arquivos].map(([arquivo, m]) => ({ arquivo, ...m })).filter((m) => m.depois !== m.antes);
|
|
154
|
+
const erro = erroAntesDeGravar(crew.raiz, mudancas);
|
|
155
|
+
return erro ? { erro } : { mudancas, iguais };
|
|
156
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Linha de comando do `conserto.mjs`: a crew, os itens de `--aplicar` e a ajuda.
|
|
2
|
+
// Spec: fase-u4a-conserto-de-crews.md, §3 (repositório do OpenCrew).
|
|
3
|
+
|
|
4
|
+
export const USO = 'Uso: node _opencrew/core/scripts/conserto.mjs --crew "crews/<crew>" [--aplicar "<item>" …]';
|
|
5
|
+
export const AJUDA = [
|
|
6
|
+
USO,
|
|
7
|
+
'Sem --aplicar: só lê a crew e mostra o que há para consertar.',
|
|
8
|
+
'Itens de --aplicar (cada gravação deixa uma cópia .bak do arquivo de antes):',
|
|
9
|
+
' manifesto refaz o crew-party.csv a partir dos agentes',
|
|
10
|
+
' nome:<agente>=<Nome Sobrenome> grava o nome de duas palavras no arquivo do agente',
|
|
11
|
+
' formato:<passo>=<formato> grava o formato do texto no passo',
|
|
12
|
+
' fonte:<caminho>=<para que> registra um arquivo ou pasta do projeto em fontes:',
|
|
13
|
+
' proibicao:<n>=<trecho> o trecho do item n vira trava do verificador',
|
|
14
|
+
' proibicao:<n>=revisao-humana o item n fica para o revisor',
|
|
15
|
+
' irreversivel:<passo> marca o passo que publica ou envia',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const TIPOS = ['manifesto', 'nome', 'formato', 'fonte', 'proibicao', 'irreversivel'];
|
|
19
|
+
const OPCAO = /^--(crew|aplicar|ajuda)(?:=(.*))?$/s;
|
|
20
|
+
const ITEM = /^([a-z-]+)(?::([^=]*))?(?:=(.*))?$/s;
|
|
21
|
+
|
|
22
|
+
/** Texto que veio da linha de comando e volta numa mensagem: uma linha só, até 200 caracteres. */
|
|
23
|
+
export const limpar = (valor) => String(valor).replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `argv` → `{ crew, itens, ajuda, estranho }`. Opção vale como `--nome valor` e `--nome=valor`;
|
|
27
|
+
* `--aplicar` pode repetir. `estranho`: o primeiro argumento que não é opção conhecida.
|
|
28
|
+
*/
|
|
29
|
+
export function lerArgs(argv) {
|
|
30
|
+
const args = { crew: undefined, itens: [], ajuda: false, estranho: undefined };
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const [, nome, colado] = argv[i].match(OPCAO) ?? [];
|
|
33
|
+
if (!nome) args.estranho ??= argv[i];
|
|
34
|
+
else if (nome === 'ajuda') args.ajuda = true;
|
|
35
|
+
else {
|
|
36
|
+
const valor = colado ?? (i + 1 < argv.length ? argv[++i] : '');
|
|
37
|
+
if (nome === 'crew') args.crew = valor;
|
|
38
|
+
else args.itens.push(valor);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return args;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Um item de `--aplicar`: `tipo[:alvo][=valor]`.
|
|
46
|
+
* @returns {{ tipo: string, alvo: string, valor: string, escrito: string } | null} null = tipo desconhecido
|
|
47
|
+
*/
|
|
48
|
+
export function lerItem(escrito) {
|
|
49
|
+
const [, tipo, alvo = '', valor = ''] = String(escrito).match(ITEM) ?? [];
|
|
50
|
+
return TIPOS.includes(tipo) ? { tipo, alvo: alvo.trim(), valor: valor.trim(), escrito: limpar(escrito) } : null;
|
|
51
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Leitura de uma crew para o conserto: `crew.yaml`, os passos do pipeline, os agentes, o manifesto
|
|
2
|
+
// e a memória. A leitura é tolerante (aceita as formas que versões antigas gravaram) e nunca grava.
|
|
3
|
+
// Spec: fase-u4a-conserto-de-crews.md, regra 5 (repositório do OpenCrew).
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { realDentroDe } from '../comum.mjs';
|
|
7
|
+
import { lerFrontmatter, semBom } from '../verificar/leitura.mjs';
|
|
8
|
+
|
|
9
|
+
/** O texto do arquivo como está no disco (com BOM e fim de linha), ou null quando não existe. */
|
|
10
|
+
export function lerBruto(arquivo) {
|
|
11
|
+
return existsSync(arquivo) && statSync(arquivo).isFile() ? readFileSync(arquivo, 'utf8') : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Valor de uma linha de YAML simples: sem aspas e sem o comentário do fim. */
|
|
15
|
+
const limpo = (valor) => valor.replace(/\s+#.*$/, '').trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
16
|
+
const PAR = /^\s*(?:-\s+)?([A-Za-z_]+)\s*:\s*(.*)$/;
|
|
17
|
+
|
|
18
|
+
/** As entradas de `steps:` do `pipeline.yaml`, na ordem: `{ step, file, on_reject, … }`. */
|
|
19
|
+
export function entradasDoPipeline(texto) {
|
|
20
|
+
const entradas = [];
|
|
21
|
+
let dentro = false;
|
|
22
|
+
for (const linha of semBom(texto ?? '').split(/\r?\n/)) {
|
|
23
|
+
if (!linha.trim() || /^\s*#/.test(linha)) continue; // linha em branco e comentário não mudam a leitura
|
|
24
|
+
if (/^\S/.test(linha) && !/^-\s/.test(linha)) dentro = /^steps\s*:/.test(linha);
|
|
25
|
+
else if (dentro) anotar(entradas, linha);
|
|
26
|
+
}
|
|
27
|
+
return entradas.map(({ coluna: _coluna, ...entrada }) => entrada);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Só valem as chaves da própria entrada (na coluna da primeira): chave de um bloco de dentro não conta. */
|
|
31
|
+
function anotar(entradas, linha) {
|
|
32
|
+
const item = linha.match(/^(\s*-\s+)(.*)$/);
|
|
33
|
+
if (item) entradas.push({ coluna: item[1].length });
|
|
34
|
+
const atual = entradas.at(-1);
|
|
35
|
+
if (!atual || (!item && linha.match(/^\s*/)[0].length !== atual.coluna)) return;
|
|
36
|
+
const par = linha.match(PAR);
|
|
37
|
+
if (par) atual[par[1]] = limpo(par[2]);
|
|
38
|
+
else if (item) atual.file = limpo(item[2]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Onde está o arquivo do passo: com ou sem `steps/` na frente (regra 5). O passo é sempre um
|
|
43
|
+
* arquivo de dentro da crew: caminho absoluto ou com `..` não é lido nem alterado.
|
|
44
|
+
*/
|
|
45
|
+
function arquivoDoPasso(pasta, citado) {
|
|
46
|
+
const partes = citado.split(/[\\/]/);
|
|
47
|
+
if (path.isAbsolute(citado) || /^[A-Za-z]:/.test(citado) || partes.includes('..')) return null;
|
|
48
|
+
const candidatos = [['pipeline', 'steps', citado], ['pipeline', citado], [citado]];
|
|
49
|
+
const achado = candidatos.map((c) => path.join(pasta, ...c)).find((c) => realDentroDe(pasta, c) && lerBruto(c) !== null);
|
|
50
|
+
return achado ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Número inteiro escrito como número ou entre aspas; senão, null. */
|
|
54
|
+
const inteiro = (v) => (/^\d+$/.test(String(v ?? '').trim()) ? Number(v) : null);
|
|
55
|
+
|
|
56
|
+
function passoDe(pasta, entrada, posicao) {
|
|
57
|
+
const arquivo = entrada.file ? arquivoDoPasso(pasta, entrada.file) : null;
|
|
58
|
+
const bruto = arquivo ? lerBruto(arquivo) : null;
|
|
59
|
+
const dados = (bruto && lerFrontmatter(semBom(bruto))) || {};
|
|
60
|
+
const volta = dados.on_reject ?? entrada.on_reject;
|
|
61
|
+
return {
|
|
62
|
+
numero: inteiro(entrada.step) ?? posicao + 1,
|
|
63
|
+
citado: entrada.file ?? '',
|
|
64
|
+
arquivo,
|
|
65
|
+
bruto,
|
|
66
|
+
dados,
|
|
67
|
+
checkpoint: dados.type === 'checkpoint',
|
|
68
|
+
revisao: volta !== undefined && volta !== '',
|
|
69
|
+
volta: inteiro(volta),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Sem `pipeline.yaml` (ou sem entradas), os passos são os arquivos de `pipeline/steps`, em ordem. */
|
|
74
|
+
function entradasDaPasta(pasta) {
|
|
75
|
+
const dir = path.join(pasta, 'pipeline', 'steps');
|
|
76
|
+
if (!existsSync(dir)) return [];
|
|
77
|
+
return readdirSync(dir).filter((f) => f.endsWith('.md')).sort().map((file) => ({ file }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Lista de um frontmatter: `chave: [a, b]` ou um item `- a` por linha. */
|
|
81
|
+
export function listaDe(bruto, chave) {
|
|
82
|
+
const fm = semBom(bruto ?? '').match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
|
|
83
|
+
const linhas = fm.split(/\r?\n/);
|
|
84
|
+
const i = linhas.findIndex((l) => new RegExp(`^${chave}\\s*:`).test(l));
|
|
85
|
+
if (i < 0) return [];
|
|
86
|
+
const naLinha = linhas[i].slice(linhas[i].indexOf(':') + 1).trim();
|
|
87
|
+
if (naLinha) return naLinha.replace(/^\[|\]$/g, '').split(',').map(limpo).filter(Boolean);
|
|
88
|
+
const itens = [];
|
|
89
|
+
for (let n = i + 1; n < linhas.length && /^\s*-\s+/.test(linhas[n]); n++) itens.push(limpo(linhas[n].replace(/^\s*-\s+/, '')));
|
|
90
|
+
return itens;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function agentesDe(pasta) {
|
|
94
|
+
const dir = path.join(pasta, 'agents');
|
|
95
|
+
if (!existsSync(dir)) return [];
|
|
96
|
+
const arquivos = readdirSync(dir).filter((f) => f.endsWith('.agent.md')).sort();
|
|
97
|
+
return arquivos.map((f) => {
|
|
98
|
+
const bruto = lerBruto(path.join(dir, f));
|
|
99
|
+
return { id: f.slice(0, -'.agent.md'.length), arquivo: path.join(dir, f), bruto, dados: lerFrontmatter(semBom(bruto)) ?? {} };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** O `id` do agente de um passo: o último segmento do que está em `agent:` (regra 5). */
|
|
104
|
+
export const idDoAgente = (passo) => String(passo.dados.agent ?? '').split(/[\\/]/).at(-1);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A crew como está no disco.
|
|
108
|
+
* @param {string} raiz pasta do projeto · @param {string} crew `crews/<nome>`, como veio em `--crew`
|
|
109
|
+
*/
|
|
110
|
+
export function lerCrew(raiz, crew) {
|
|
111
|
+
const pasta = path.resolve(raiz, crew);
|
|
112
|
+
const arquivo = (...partes) => path.join(pasta, ...partes);
|
|
113
|
+
const doPipeline = entradasDoPipeline(lerBruto(arquivo('pipeline', 'pipeline.yaml')));
|
|
114
|
+
const entradas = doPipeline.length ? doPipeline : entradasDaPasta(pasta);
|
|
115
|
+
return {
|
|
116
|
+
raiz,
|
|
117
|
+
pasta,
|
|
118
|
+
nome: path.basename(pasta),
|
|
119
|
+
yaml: lerBruto(arquivo('crew.yaml')),
|
|
120
|
+
passos: entradas.map((e, i) => passoDe(pasta, e, i)),
|
|
121
|
+
agentes: agentesDe(pasta),
|
|
122
|
+
csv: lerBruto(arquivo('crew-party.csv')),
|
|
123
|
+
memoria: lerBruto(arquivo('_memory', 'memories.md')),
|
|
124
|
+
arquivos: { yaml: arquivo('crew.yaml'), csv: arquivo('crew-party.csv'), memoria: arquivo('_memory', 'memories.md') },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// As edições do conserto, como funções de texto: cada uma muda só a linha que o item pede e
|
|
2
|
+
// devolve o resto igual, byte a byte (fim de linha, BOM, comentários e ordem das chaves).
|
|
3
|
+
// Spec: fase-u4a-conserto-de-crews.md, regras 3 e 4 (repositório do OpenCrew).
|
|
4
|
+
|
|
5
|
+
import { lerFrontmatter, semBom } from '../verificar/leitura.mjs';
|
|
6
|
+
|
|
7
|
+
const BOM = String.fromCharCode(0xfeff);
|
|
8
|
+
const emLinhas = (texto) => texto.split(/(?<=\n)/);
|
|
9
|
+
const fimDe = (linha) => linha.match(/\r?\n$/)?.[0] ?? '';
|
|
10
|
+
const eolDe = (texto) => (texto.includes('\r\n') ? '\r\n' : '\n');
|
|
11
|
+
const ehCerca = (linha) => /^\uFEFF?---\s*$/.test(linha);
|
|
12
|
+
|
|
13
|
+
const semAspas = (v) => v.replace(/\r?\n$/, '').replace(/\s+#.*$/, '').trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
14
|
+
|
|
15
|
+
/** A linha da chave tem o valor nela mesma? ("não" quando ele continua nas linhas de baixo) */
|
|
16
|
+
function valorNaLinha(linhas, i, fim) {
|
|
17
|
+
const valor = semAspas(linhas[i].slice(linhas[i].indexOf(':') + 1));
|
|
18
|
+
const continua = i + 1 < fim && /^(?:\s+\S|-\s)/.test(linhas[i + 1]);
|
|
19
|
+
return valor !== '' && !/^[>|][+-]?$/.test(valor) && !continua;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* O frontmatter do arquivo com `chave: valor`. A linha da chave é trocada no lugar; se não existe,
|
|
24
|
+
* entra depois da linha `depoisDe:` ou, na falta dela, no fim do frontmatter. Valor que já é o
|
|
25
|
+
* pedido (com aspas ou comentário): o texto volta igual.
|
|
26
|
+
* @returns {string|null} null quando o arquivo não tem frontmatter, ou quando o valor da chave
|
|
27
|
+
* ocupa mais de uma linha (o script não reescreve um bloco)
|
|
28
|
+
*/
|
|
29
|
+
export function comChave(texto, chave, valor, depoisDe) {
|
|
30
|
+
const linhas = emLinhas(texto);
|
|
31
|
+
const fim = linhas.findIndex((l, i) => i > 0 && ehCerca(l));
|
|
32
|
+
if (!linhas.length || !ehCerca(linhas[0]) || fim < 0 || !lerFrontmatter(semBom(texto))) return null;
|
|
33
|
+
const eol = fimDe(linhas[0]) || eolDe(texto);
|
|
34
|
+
const acha = (nome) => linhas.findIndex((l, i) => i > 0 && i < fim && new RegExp(`^${nome}\\s*:`).test(l));
|
|
35
|
+
const nova = `${chave}: ${valor}${eol}`;
|
|
36
|
+
const atual = acha(chave);
|
|
37
|
+
if (atual < 0) linhas.splice(depoisDe && acha(depoisDe) >= 0 ? acha(depoisDe) + 1 : fim, 0, nova);
|
|
38
|
+
else if (!valorNaLinha(linhas, atual, fim)) return null;
|
|
39
|
+
else if (semAspas(linhas[atual].slice(linhas[atual].indexOf(':') + 1)) === semAspas(valor)) return texto;
|
|
40
|
+
else linhas[atual] = nova;
|
|
41
|
+
return linhas.join('');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** O arquivo com `# titulo` na primeira linha de título de nível 1 depois do frontmatter; sem ela, o texto igual. */
|
|
45
|
+
export function comTitulo(texto, titulo) {
|
|
46
|
+
const linhas = emLinhas(texto);
|
|
47
|
+
const fim = ehCerca(linhas[0] ?? '') ? linhas.findIndex((l, i) => i > 0 && ehCerca(l)) : -1;
|
|
48
|
+
const i = linhas.findIndex((l, n) => n > fim && /^# /.test(l));
|
|
49
|
+
if (i >= 0) linhas[i] = `# ${titulo}${fimDe(linhas[i])}`;
|
|
50
|
+
return linhas.join('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Valor de YAML: como está quando é simples; entre aspas quando tem `: `, `#` ou aspas. */
|
|
54
|
+
const escrito = (valor) => (/^[^\s"'#[\]{}&*!|>%@`-][^"#\r\n]*$/u.test(valor) && !/:\s|\s$/.test(valor) ? valor : JSON.stringify(valor));
|
|
55
|
+
const normal = (c) => c.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
56
|
+
const mesmoCaminho = (a, b) => normal(a) === normal(b);
|
|
57
|
+
const CAMINHO = /^[ \t]*(?:-[ \t]*)?caminho:[ \t]*(.*)$/;
|
|
58
|
+
|
|
59
|
+
/** Onde começa e acaba a lista de `fontes:` (a linha da chave e a última linha do bloco), ou null. */
|
|
60
|
+
function blocoDeFontes(linhas) {
|
|
61
|
+
const inicio = linhas.findIndex((l) => /^fontes\s*:/.test(l.replace(BOM, '')));
|
|
62
|
+
if (inicio < 0) return null;
|
|
63
|
+
let fim = inicio;
|
|
64
|
+
for (let i = inicio + 1; i < linhas.length && (!linhas[i].trim() || /^(?:\s|-\s)/.test(linhas[i])); i++) {
|
|
65
|
+
if (linhas[i].trim()) fim = i;
|
|
66
|
+
}
|
|
67
|
+
return { inicio, fim };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* O `crew.yaml` com mais uma fonte, no formato que o Build grava. Fonte que já está: texto igual.
|
|
72
|
+
* @returns {string|null} null quando `fontes:` está escrita numa linha só (`fontes: [{…}]`): o
|
|
73
|
+
* script não reescreve essa forma
|
|
74
|
+
*/
|
|
75
|
+
export function comFonte(texto, caminho, paraQue) {
|
|
76
|
+
const eol = eolDe(texto);
|
|
77
|
+
const linhas = emLinhas(texto);
|
|
78
|
+
const bloco = blocoDeFontes(linhas);
|
|
79
|
+
const naLinha = bloco ? semAspas(linhas[bloco.inicio].slice(linhas[bloco.inicio].indexOf(':') + 1)) : '';
|
|
80
|
+
if (naLinha && naLinha !== '[]') return null;
|
|
81
|
+
const daLista = bloco ? linhas.slice(bloco.inicio + 1, bloco.fim + 1).map((l) => l.replace(/\r?\n$/, '')) : [];
|
|
82
|
+
if (daLista.some((l) => CAMINHO.test(l) && mesmoCaminho(semAspas(l.match(CAMINHO)[1]), caminho))) return texto;
|
|
83
|
+
const recuo = daLista.find((l) => /^\s*-\s/.test(l))?.match(/^\s*/)[0] ?? ' ';
|
|
84
|
+
const item = [`${recuo}- caminho: ${escrito(caminho)}${eol}`, `${recuo} para_que: ${escrito(paraQue)}${eol}`];
|
|
85
|
+
if (!bloco) return `${texto}${texto && !fimDe(texto) ? eol : ''}${texto.trim() ? eol : ''}fontes:${eol}${item.join('')}`;
|
|
86
|
+
if (naLinha === '[]') linhas[bloco.inicio] = `fontes:${fimDe(linhas[bloco.inicio]) || eol}`;
|
|
87
|
+
if (!fimDe(linhas[bloco.fim])) linhas[bloco.fim] += eol;
|
|
88
|
+
linhas.splice(bloco.fim + 1, 0, ...item);
|
|
89
|
+
return linhas.join('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A linha `indice` com `sufixo` no fim, antes do fim de linha. */
|
|
93
|
+
export function comSufixo(texto, indice, sufixo) {
|
|
94
|
+
const linhas = emLinhas(texto);
|
|
95
|
+
const fim = fimDe(linhas[indice]);
|
|
96
|
+
linhas[indice] = `${linhas[indice].slice(0, linhas[indice].length - fim.length).trimEnd()}${sufixo}${fim}`;
|
|
97
|
+
return linhas.join('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Os campos de uma linha de CSV: aspas duplas protegem vírgula e espaço; `""` é uma aspa. */
|
|
101
|
+
function campos(linha) {
|
|
102
|
+
const saida = [];
|
|
103
|
+
const rx = /(?:^|,)(?:"((?:[^"]|"")*)"|([^,]*))/g;
|
|
104
|
+
for (const m of linha.matchAll(rx)) saida.push(m[1] !== undefined ? m[1].replace(/""/g, '"') : m[2].trim());
|
|
105
|
+
return saida;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** O manifesto lido: as colunas e uma linha por agente (`{ coluna: valor }`). */
|
|
109
|
+
export function lerCsv(texto) {
|
|
110
|
+
const linhas = (texto ?? '').replace(/^\uFEFF/, '').split(/\r?\n/).filter((l) => l.trim());
|
|
111
|
+
const colunas = linhas.length ? campos(linhas[0]) : [];
|
|
112
|
+
return { colunas, linhas: linhas.slice(1).map((l) => Object.fromEntries(campos(l).map((v, i) => [colunas[i] ?? `c${i}`, v]))) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const COLUNAS = ['id', 'displayName', 'title', 'icon', 'path', 'execution'];
|
|
116
|
+
const campo = (v) => (/[\s,"]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* O manifesto refeito a partir dos agentes: as 6 colunas, na ordem do manifesto antigo (agente novo
|
|
120
|
+
* vai para o fim); `execution` vem do agente, depois do manifesto antigo, depois `inline`.
|
|
121
|
+
*/
|
|
122
|
+
export function montarCsv(agentes, antigo) {
|
|
123
|
+
const velho = lerCsv(antigo);
|
|
124
|
+
const ordem = (a) => { const i = velho.linhas.findIndex((l) => l.id === a.id); return i < 0 ? Infinity : i; };
|
|
125
|
+
const emOrdem = [...agentes].sort((a, b) => ordem(a) - ordem(b));
|
|
126
|
+
const eol = antigo ? eolDe(antigo) : '\n';
|
|
127
|
+
const linha = (a) => {
|
|
128
|
+
const antes = velho.linhas.find((l) => l.id === a.id) ?? {};
|
|
129
|
+
const d = a.dados;
|
|
130
|
+
return [a.id, d.name ?? '', d.title ?? antes.title ?? '', d.icon ?? antes.icon ?? '', `./agents/${a.id}.agent.md`, d.execution ?? antes.execution ?? 'inline'].map((v) => campo(String(v))).join(',');
|
|
131
|
+
};
|
|
132
|
+
return [COLUNAS.join(','), ...emOrdem.map(linha), ''].join(eol);
|
|
133
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A gravação do conserto: o que impede de gravar é visto antes de tocar em qualquer arquivo; cada
|
|
2
|
+
// arquivo alterado ganha antes a cópia `.bak` (a que já existe fica).
|
|
3
|
+
// Spec: fase-u4a-conserto-de-crews.md, regras 1 e 2 (repositório do OpenCrew).
|
|
4
|
+
import { accessSync, constants, existsSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { relativoAoProjeto } from '../comum.mjs';
|
|
6
|
+
|
|
7
|
+
const MSG = {
|
|
8
|
+
naoUtf8: (arquivo) => `Não altero ${arquivo}: o arquivo não está em UTF-8. Salve-o em UTF-8 e rode de novo.`,
|
|
9
|
+
somenteLeitura: (arquivo) => `Não consigo gravar em ${arquivo} (arquivo protegido ou aberto em outro programa). Nada foi gravado.`,
|
|
10
|
+
parouNoMeio: (gravados, arquivo, motivo) => `A gravação parou em ${arquivo}: ${motivo}. Já gravados: ${gravados.join(', ') || 'nenhum'} (a cópia .bak de cada um tem o texto de antes).`,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const INVALIDO = String.fromCharCode(0xfffd); // o que sobra de um byte que não é UTF-8
|
|
14
|
+
|
|
15
|
+
function podeGravar(arquivo) {
|
|
16
|
+
try {
|
|
17
|
+
accessSync(arquivo, constants.W_OK);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** O que impede a gravação, visto antes de tocar em qualquer arquivo: texto que não é UTF-8, arquivo protegido. */
|
|
25
|
+
export function erroAntesDeGravar(raiz, mudancas) {
|
|
26
|
+
const rel = (m) => relativoAoProjeto(raiz, m.arquivo);
|
|
27
|
+
const estranho = mudancas.find((m) => m.antes?.includes(INVALIDO));
|
|
28
|
+
if (estranho) return MSG.naoUtf8(rel(estranho));
|
|
29
|
+
const fechado = mudancas.find((m) => m.antes !== null && !podeGravar(m.arquivo));
|
|
30
|
+
return fechado ? MSG.somenteLeitura(rel(fechado)) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Grava as mudanças; antes de cada arquivo, a cópia `.bak` do que estava lá (se ainda não existe).
|
|
35
|
+
* @returns {string[]} as linhas do relatório, com os caminhos relativos ao projeto
|
|
36
|
+
*/
|
|
37
|
+
export function gravar(raiz, mudancas) {
|
|
38
|
+
const linhas = [];
|
|
39
|
+
const gravados = [];
|
|
40
|
+
for (const { arquivo, antes, depois } of mudancas) {
|
|
41
|
+
const rel = relativoAoProjeto(raiz, arquivo);
|
|
42
|
+
const copia = `${arquivo}.bak`;
|
|
43
|
+
const jaHavia = existsSync(copia);
|
|
44
|
+
try {
|
|
45
|
+
if (antes !== null && !jaHavia) writeFileSync(copia, antes, 'utf8');
|
|
46
|
+
writeFileSync(arquivo, depois, 'utf8');
|
|
47
|
+
} catch (falha) {
|
|
48
|
+
throw new Error(MSG.parouNoMeio(gravados, rel, falha?.code ?? falha?.message ?? falha), { cause: falha });
|
|
49
|
+
}
|
|
50
|
+
gravados.push(rel);
|
|
51
|
+
linhas.push(`Gravei: ${rel}`);
|
|
52
|
+
if (antes !== null) linhas.push(`Cópia: ${rel}.bak${jaHavia ? ' (mantive a cópia que já existia)' : ''}`);
|
|
53
|
+
}
|
|
54
|
+
return linhas;
|
|
55
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Conserto de uma crew que já existe: mostra o que falta para as melhorias do runtime valerem nela
|
|
3
|
+
// e, com --aplicar, grava um item por vez. Quem grava em `crews/` é este script, não a IA.
|
|
4
|
+
// Uso (na pasta do projeto):
|
|
5
|
+
// node _opencrew/core/scripts/conserto.mjs --crew "crews/<crew>" só lê
|
|
6
|
+
// node _opencrew/core/scripts/conserto.mjs --crew "crews/<crew>" --aplicar "<item>" grava
|
|
7
|
+
// Itens de --aplicar: manifesto · nome:<agente>=<Nome Sobrenome> · formato:<passo>=<formato> ·
|
|
8
|
+
// fonte:<caminho>=<para que> · proibicao:<n>=<trecho> · proibicao:<n>=revisao-humana ·
|
|
9
|
+
// irreversivel:<passo> (ver --ajuda).
|
|
10
|
+
// Só grava `crew.yaml`, `crew-party.csv`, `agents/*.agent.md`, `pipeline/steps/*.md` e
|
|
11
|
+
// `_memory/memories.md` da crew, e a cópia `<arquivo>.bak` de cada um (a cópia que já existe
|
|
12
|
+
// não é sobrescrita). Nunca apaga.
|
|
13
|
+
// Última linha da saída: CONSERTO:OK (nada a consertar) · CONSERTO:PENDENTE (há achados) ·
|
|
14
|
+
// CONSERTO:APLICADO (tudo o que foi pedido está gravado) · CONSERTO:ERRO (nada foi gravado).
|
|
15
|
+
// Código de saída: 0, menos com CONSERTO:ERRO (1).
|
|
16
|
+
// Spec: fase-u4a-conserto-de-crews.md (repositório do OpenCrew).
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { ehPrincipal, erroDeUso } from './comum.mjs';
|
|
20
|
+
import { diagnosticar } from './conserto/achados.mjs';
|
|
21
|
+
import { planejar } from './conserto/aplicar.mjs';
|
|
22
|
+
import { AJUDA, lerArgs, lerItem, limpar } from './conserto/argumentos.mjs';
|
|
23
|
+
import { lerCrew } from './conserto/crew.mjs';
|
|
24
|
+
import { gravar } from './conserto/gravar.mjs';
|
|
25
|
+
|
|
26
|
+
const MSG = {
|
|
27
|
+
estranho: (arg) => `Argumento desconhecido: ${limpar(arg)}. Veja as opções com --ajuda.`,
|
|
28
|
+
semYaml: (crew) => `A pasta ${limpar(crew)} não tem \`crew.yaml\`: não é uma crew.`,
|
|
29
|
+
itemDesconhecido: (item) => `Item desconhecido em --aplicar: ${limpar(item)}. Veja os itens com --ajuda.`,
|
|
30
|
+
emDia: (nome) => `A crew "${nome}" está em dia: não há o que consertar.`,
|
|
31
|
+
titulo: (nome, n) => `Conserto da crew "${nome}" — ${n} ${n === 1 ? 'achado' : 'achados'}`,
|
|
32
|
+
jaEstava: (item) => `Já estava assim: ${item}`,
|
|
33
|
+
falha: (motivo) => `Não consegui consertar: ${String(motivo).replace(/\s+/g, ' ').trim()}`,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** O que está errado na chamada, antes de ler a crew; ou null. */
|
|
37
|
+
function erroDaChamada(raiz, args) {
|
|
38
|
+
if (args.estranho !== undefined) return MSG.estranho(args.estranho);
|
|
39
|
+
const deUso = erroDeUso({ raiz, faltando: args.crew ? [] : ['--crew'], crew: args.crew });
|
|
40
|
+
if (deUso) return deUso;
|
|
41
|
+
if (!existsSync(path.resolve(raiz, args.crew, 'crew.yaml'))) return MSG.semYaml(args.crew);
|
|
42
|
+
const desconhecido = args.itens.find((i) => !lerItem(i));
|
|
43
|
+
return desconhecido === undefined ? null : MSG.itemDesconhecido(desconhecido);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** As linhas do diagnóstico: um bloco por achado e a linha de situação. */
|
|
47
|
+
function relatorio(crew) {
|
|
48
|
+
const achados = diagnosticar(crew);
|
|
49
|
+
if (!achados.length) return [MSG.emDia(crew.nome), 'CONSERTO:OK'];
|
|
50
|
+
const blocos = achados.flatMap(({ codigo, linhas: [titulo, ...resto] }) => ['', `[${codigo}] ${titulo}`, ...resto.map((l) => ` ${l}`)]);
|
|
51
|
+
return [MSG.titulo(crew.nome, achados.length), ...blocos, '', 'CONSERTO:PENDENTE'];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** As linhas de um `--aplicar`: o que foi gravado, as cópias e a linha de situação. */
|
|
55
|
+
function aplicar(raiz, crew, itens) {
|
|
56
|
+
const plano = planejar(crew, itens.map(lerItem));
|
|
57
|
+
if (plano.erro) return [plano.erro, 'CONSERTO:ERRO'];
|
|
58
|
+
return [...gravar(raiz, plano.mudancas), ...plano.iguais.map(MSG.jaEstava), 'CONSERTO:APLICADO'];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string[]} argv
|
|
63
|
+
* @param {object} [deps] `cwd` (a pasta do projeto) e `escrever`
|
|
64
|
+
* @returns {number} 0 = a crew foi lida (e, com --aplicar, gravada) · 1 = CONSERTO:ERRO
|
|
65
|
+
*/
|
|
66
|
+
export function main(argv, deps = {}) {
|
|
67
|
+
const { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = deps;
|
|
68
|
+
const args = lerArgs(argv);
|
|
69
|
+
if (args.ajuda) return AJUDA.forEach((l) => escrever(l)) ?? 0;
|
|
70
|
+
let linhas;
|
|
71
|
+
try {
|
|
72
|
+
const erro = erroDaChamada(cwd, args);
|
|
73
|
+
const crew = erro ? null : lerCrew(cwd, args.crew);
|
|
74
|
+
linhas = erro ? [erro, 'CONSERTO:ERRO'] : args.itens.length ? aplicar(cwd, crew, args.itens) : relatorio(crew);
|
|
75
|
+
} catch (falha) {
|
|
76
|
+
linhas = [MSG.falha(falha?.message ?? falha), 'CONSERTO:ERRO'];
|
|
77
|
+
}
|
|
78
|
+
linhas.forEach((l) => escrever(l));
|
|
79
|
+
return linhas.at(-1) === 'CONSERTO:ERRO' ? 1 : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (ehPrincipal(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|