@fontdata/ai-harness 2.6.0 → 3.0.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/.claude-plugin/plugin.json +4 -1
- package/README.md +34 -1
- package/dist/banco-dados/ambiente.js +98 -0
- package/dist/banco-dados/cadastro.js +179 -0
- package/dist/banco-dados/command.js +432 -0
- package/dist/banco-dados/conexao.js +127 -0
- package/dist/banco-dados/drivers/index.js +13 -0
- package/dist/banco-dados/drivers/mariadb.js +121 -0
- package/dist/banco-dados/drivers/postgres.js +157 -0
- package/dist/banco-dados/drivers/sqlite.js +92 -0
- package/dist/banco-dados/drivers/tipos.js +29 -0
- package/dist/banco-dados/ferramentas.js +241 -0
- package/dist/banco-dados/lgpd/aplicar.js +168 -0
- package/dist/banco-dados/lgpd/detectar.js +210 -0
- package/dist/banco-dados/lgpd/mascarar.js +114 -0
- package/dist/banco-dados/mcp.js +101 -0
- package/dist/banco-dados/somente-leitura.js +221 -0
- package/dist/cli.js +22 -0
- package/dist/config/config.js +253 -2
- package/dist/doctor/checks.js +107 -2
- package/dist/init.js +25 -1
- package/dist/install/flags.js +9 -1
- package/dist/install/mcp.js +77 -0
- package/dist/install/plan.js +26 -3
- package/dist/install/settings.js +21 -2
- package/dist/update/update.js +108 -14
- package/package.json +4 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const COMANDOS_INICIAIS = new Set(['select', 'with', 'explain', 'show', 'describe', 'desc']);
|
|
2
|
+
const PALAVRAS_PROIBIDAS = new Set([
|
|
3
|
+
'insert', 'update', 'delete', 'merge', 'drop', 'alter', 'create', 'truncate', 'grant', 'revoke',
|
|
4
|
+
'copy', 'call', 'lock', 'vacuum', 'attach', 'detach', 'into', 'outfile',
|
|
5
|
+
]);
|
|
6
|
+
const FUNCOES_PROIBIDAS = [
|
|
7
|
+
'pg_terminate_backend', 'pg_cancel_backend', 'pg_reload_conf', 'pg_rotate_logfile', 'set_config',
|
|
8
|
+
'pg_read_file', 'pg_read_binary_file', 'pg_ls_dir', 'pg_ls_logdir', 'pg_ls_waldir', 'pg_stat_file',
|
|
9
|
+
'pg_switch_wal', 'pg_promote', 'pg_notify', 'lo_import', 'lo_export', 'dblink', 'dblink_exec',
|
|
10
|
+
'load_file', 'sys_exec', 'sys_eval', 'query_to_xml', 'query_to_xmlschema', 'query_to_xml_and_xmlschema',
|
|
11
|
+
'cursor_to_xml', 'cursor_to_xmlschema', 'ts_stat', 'crosstab', 'crosstab2', 'crosstab3', 'crosstab4',
|
|
12
|
+
'connectby',
|
|
13
|
+
];
|
|
14
|
+
const INICIO_PALAVRA = /[\p{L}_]/u;
|
|
15
|
+
const CONTINUA_PALAVRA = /[\p{L}\p{N}_$]/u;
|
|
16
|
+
const DIGITO = /[0-9]/;
|
|
17
|
+
const ESPACO = /\s/;
|
|
18
|
+
const TAG_DOLAR = /^\$([\p{L}_][\p{L}\p{N}_]*)?\$/u;
|
|
19
|
+
export function tokenizar(sql, dialeto) {
|
|
20
|
+
return tokenizarCom(sql, dialeto, { barraInvertida: dialeto === 'mariadb' });
|
|
21
|
+
}
|
|
22
|
+
function tokenizarCom(sql, dialeto, regras) {
|
|
23
|
+
const tokens = [];
|
|
24
|
+
let i = 0;
|
|
25
|
+
const n = sql.length;
|
|
26
|
+
const push = (tipo, fim, valor) => {
|
|
27
|
+
tokens.push({ tipo, valor: valor ?? sql.slice(i, fim), inicio: i });
|
|
28
|
+
i = fim;
|
|
29
|
+
};
|
|
30
|
+
const fecharCitado = (aspa, escapa) => {
|
|
31
|
+
let j = i + 1;
|
|
32
|
+
while (j < n) {
|
|
33
|
+
const c = sql[j];
|
|
34
|
+
if (escapa && c === '\\') {
|
|
35
|
+
j += 2;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === aspa) {
|
|
39
|
+
if (sql[j + 1] === aspa) {
|
|
40
|
+
j += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
return j + 1;
|
|
44
|
+
}
|
|
45
|
+
j++;
|
|
46
|
+
}
|
|
47
|
+
throw new Error(aspa === "'" || (dialeto === 'mariadb' && aspa === '"') ? 'string sem fechamento' : 'identificador sem fechamento');
|
|
48
|
+
};
|
|
49
|
+
const citado = (aspa) => {
|
|
50
|
+
const fim = fecharCitado(aspa, false);
|
|
51
|
+
push('identificador-citado', fim, sql.slice(i + 1, fim - 1).split(aspa + aspa).join(aspa).toLowerCase());
|
|
52
|
+
};
|
|
53
|
+
while (i < n) {
|
|
54
|
+
const c = sql[i];
|
|
55
|
+
const prox = sql[i + 1];
|
|
56
|
+
if (ESPACO.test(c)) {
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === '/' && prox === '*') {
|
|
61
|
+
const resto = sql.slice(i + 2, i + 4);
|
|
62
|
+
if (resto.startsWith('!') || /^m!/i.test(resto)) {
|
|
63
|
+
throw new Error('comentário executável /*! ou /*M! não permitido');
|
|
64
|
+
}
|
|
65
|
+
push('comentario', fecharComentarioBloco(sql, i, dialeto === 'postgres'));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const comentarioLinha = (c === '-' && prox === '-' && (dialeto !== 'mariadb' || espacoOuFim(sql, i + 2))) ||
|
|
69
|
+
(c === '#' && dialeto === 'mariadb');
|
|
70
|
+
if (comentarioLinha) {
|
|
71
|
+
const quebra = sql.indexOf('\n', i);
|
|
72
|
+
push('comentario', quebra === -1 ? n : quebra);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (dialeto === 'postgres') {
|
|
76
|
+
if ((c === 'e' || c === 'E') && prox === "'") {
|
|
77
|
+
throw new Error("prefixo E' não permitido");
|
|
78
|
+
}
|
|
79
|
+
if ((c === 'u' || c === 'U') && prox === '&' && (sql[i + 2] === "'" || sql[i + 2] === '"')) {
|
|
80
|
+
throw new Error('prefixo U& não permitido');
|
|
81
|
+
}
|
|
82
|
+
if (c === '$') {
|
|
83
|
+
if (prox !== undefined && DIGITO.test(prox)) {
|
|
84
|
+
let j = i + 1;
|
|
85
|
+
while (j < n && DIGITO.test(sql[j]))
|
|
86
|
+
j++;
|
|
87
|
+
push('simbolo', j);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const tag = TAG_DOLAR.exec(sql.slice(i));
|
|
91
|
+
if (tag) {
|
|
92
|
+
const fim = sql.indexOf(tag[0], i + tag[0].length);
|
|
93
|
+
if (fim === -1)
|
|
94
|
+
throw new Error('string sem fechamento');
|
|
95
|
+
push('string', fim + tag[0].length);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (c === "'") {
|
|
101
|
+
push('string', fecharCitado("'", regras.barraInvertida));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (c === '"') {
|
|
105
|
+
if (dialeto === 'mariadb')
|
|
106
|
+
push('string', fecharCitado('"', regras.barraInvertida));
|
|
107
|
+
else
|
|
108
|
+
citado('"');
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (c === '`' && dialeto !== 'postgres') {
|
|
112
|
+
citado('`');
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (c === '[' && dialeto === 'sqlite') {
|
|
116
|
+
const fim = sql.indexOf(']', i + 1);
|
|
117
|
+
if (fim === -1)
|
|
118
|
+
throw new Error('identificador sem fechamento');
|
|
119
|
+
push('identificador-citado', fim + 1, sql.slice(i + 1, fim).toLowerCase());
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (INICIO_PALAVRA.test(c) || (c === '$' && dialeto === 'mariadb')) {
|
|
123
|
+
let j = i + 1;
|
|
124
|
+
while (j < n && CONTINUA_PALAVRA.test(sql[j]))
|
|
125
|
+
j++;
|
|
126
|
+
push('palavra', j, sql.slice(i, j).toLowerCase());
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (DIGITO.test(c)) {
|
|
130
|
+
let j = i + 1;
|
|
131
|
+
while (j < n && /[0-9.]/.test(sql[j]))
|
|
132
|
+
j++;
|
|
133
|
+
push('simbolo', j);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
push('simbolo', i + 1);
|
|
137
|
+
}
|
|
138
|
+
return tokens;
|
|
139
|
+
}
|
|
140
|
+
function espacoOuFim(sql, j) {
|
|
141
|
+
const c = sql[j];
|
|
142
|
+
return c === undefined || ESPACO.test(c);
|
|
143
|
+
}
|
|
144
|
+
function fecharComentarioBloco(sql, inicio, aninhado) {
|
|
145
|
+
let profundidade = 1;
|
|
146
|
+
let j = inicio + 2;
|
|
147
|
+
while (j < sql.length) {
|
|
148
|
+
if (aninhado && sql[j] === '/' && sql[j + 1] === '*') {
|
|
149
|
+
profundidade++;
|
|
150
|
+
j += 2;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (sql[j] === '*' && sql[j + 1] === '/') {
|
|
154
|
+
profundidade--;
|
|
155
|
+
j += 2;
|
|
156
|
+
if (profundidade === 0)
|
|
157
|
+
return j;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
j++;
|
|
161
|
+
}
|
|
162
|
+
throw new Error('comentário sem fechamento');
|
|
163
|
+
}
|
|
164
|
+
function funcaoProibida(sql) {
|
|
165
|
+
const minusculo = sql.toLowerCase();
|
|
166
|
+
return FUNCOES_PROIBIDAS.find((nome) => new RegExp(`(?<![a-z0-9_])${nome}(?![a-z0-9_])`).test(minusculo));
|
|
167
|
+
}
|
|
168
|
+
function validarTokens(sql, tokens) {
|
|
169
|
+
const uteis = tokens.filter((t) => t.tipo !== 'comentario');
|
|
170
|
+
const primeiro = uteis[0];
|
|
171
|
+
if (!primeiro)
|
|
172
|
+
return { ok: false, motivo: 'consulta vazia' };
|
|
173
|
+
const pontos = uteis.filter((t) => t.tipo === 'simbolo' && t.valor === ';');
|
|
174
|
+
const ultimo = uteis[uteis.length - 1];
|
|
175
|
+
if (pontos.length > 1 || (pontos.length === 1 && pontos[0] !== ultimo)) {
|
|
176
|
+
return { ok: false, motivo: 'mais de um comando: ";" só é aceito no fim' };
|
|
177
|
+
}
|
|
178
|
+
if (primeiro.tipo !== 'palavra' || !COMANDOS_INICIAIS.has(primeiro.valor)) {
|
|
179
|
+
const texto = primeiro.tipo === 'palavra' ? primeiro.valor.toUpperCase() : sql.slice(primeiro.inicio).split(/\s/)[0];
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
motivo: `comando ${texto} não permitido: só SELECT, WITH, EXPLAIN, SHOW, DESCRIBE ou DESC`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const proibida = uteis.find((t) => t.tipo === 'palavra' && PALAVRAS_PROIBIDAS.has(t.valor));
|
|
186
|
+
if (proibida) {
|
|
187
|
+
return { ok: false, motivo: `palavra ${proibida.valor.toUpperCase()} não permitida em consulta de leitura` };
|
|
188
|
+
}
|
|
189
|
+
const ponto = pontos[0];
|
|
190
|
+
const final = ponto ? sql.slice(0, ponto.inicio) + sql.slice(ponto.inicio + 1) : sql;
|
|
191
|
+
return { ok: true, sql: final };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Filtro léxico de somente leitura. Falha fechado: qualquer dúvida do tokenizador vira recusa.
|
|
195
|
+
* No MariaDB a consulta precisa passar com e sem escape por `\`, cobrindo `NO_BACKSLASH_ESCAPES`.
|
|
196
|
+
*/
|
|
197
|
+
export function validarLeitura(sql, dialeto) {
|
|
198
|
+
const funcao = funcaoProibida(sql);
|
|
199
|
+
if (funcao)
|
|
200
|
+
return { ok: false, motivo: `função ${funcao} não permitida` };
|
|
201
|
+
const modos = dialeto === 'mariadb' ? [{ barraInvertida: true }, { barraInvertida: false }] : [{ barraInvertida: false }];
|
|
202
|
+
let resultado = { ok: false, motivo: 'consulta vazia' };
|
|
203
|
+
for (const regras of modos) {
|
|
204
|
+
let tokens;
|
|
205
|
+
try {
|
|
206
|
+
tokens = tokenizarCom(sql, dialeto, regras);
|
|
207
|
+
}
|
|
208
|
+
catch (erro) {
|
|
209
|
+
return { ok: false, motivo: erro.message };
|
|
210
|
+
}
|
|
211
|
+
resultado = validarTokens(sql, tokens);
|
|
212
|
+
if (!resultado.ok)
|
|
213
|
+
return resultado;
|
|
214
|
+
}
|
|
215
|
+
return resultado;
|
|
216
|
+
}
|
|
217
|
+
export function identificadoresDoSql(sql, dialeto) {
|
|
218
|
+
return tokenizar(sql, dialeto)
|
|
219
|
+
.filter((t) => t.tipo === 'palavra' || t.tipo === 'identificador-citado')
|
|
220
|
+
.map((t) => t.valor);
|
|
221
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { allCommands, loadCatalog } from './catalog.js';
|
|
6
6
|
import { runCommand, runnableIds, suggestIds } from './run.js';
|
|
7
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
/** Comandos renomeados depois da 2.7.0: o nome antigo aponta o novo em vez de cair em "Comando desconhecido". */
|
|
9
|
+
const RENAMED_COMMANDS = { banco: 'banco-dados', 'banco-mcp': 'banco-dados-mcp' };
|
|
8
10
|
const RESERVED = new Set([
|
|
9
11
|
'init',
|
|
10
12
|
'doctor',
|
|
@@ -14,6 +16,8 @@ const RESERVED = new Set([
|
|
|
14
16
|
'conflitos',
|
|
15
17
|
'comentarios',
|
|
16
18
|
'memoria',
|
|
19
|
+
'banco-dados',
|
|
20
|
+
'banco-dados-mcp',
|
|
17
21
|
'list',
|
|
18
22
|
'run',
|
|
19
23
|
'help',
|
|
@@ -47,12 +51,15 @@ Gerenciar:
|
|
|
47
51
|
init [caminho] Instala os slash commands em .claude/commands do projeto (interativo)
|
|
48
52
|
doctor [caminho] Diagnostica a instalação (arquivos, lock, skills, agents, hooks, Node)
|
|
49
53
|
update [caminho] Atualiza o que foi instalado: --check só lista, --yes aplica, --forcar sobrescreve conflitos
|
|
54
|
+
--sem-banco-dados: não registra o MCP de banco no .mcp.json e lembra a recusa no config
|
|
50
55
|
migrar [caminho] Move docs/specs, plans, bugs e investigacoes para docs/trabalhos/<id>/ (--yes aplica)
|
|
51
56
|
--reclassificar: planos já migrados em em-execucao viram concluido ou legado
|
|
52
57
|
comentarios [caminho] Instala o hook de comentários e a catraca do legado (--yes aplica)
|
|
53
58
|
memoria [caminho] Índice de quem mexeu em cada arquivo (--arquivo <caminho>, --regressao <id do bug>)
|
|
54
59
|
evento <evento> Grava no rastro do trabalho (--trabalho, --task, --nome, --resultado, --dados '{json}')
|
|
55
60
|
conflitos <id> Aponta trabalhos em execução que mexem nos mesmos arquivos (--aguardar espera liberar)
|
|
61
|
+
banco-dados <subcomando> Conexões de banco somente leitura: adicionar, listar, testar, usar, remover
|
|
62
|
+
banco-dados-mcp Servidor MCP de leitura do banco, usado pelo Claude Code (stdio)
|
|
56
63
|
list Lista todos os comandos disponíveis no catálogo
|
|
57
64
|
|
|
58
65
|
Init sem perguntas (CI):
|
|
@@ -61,6 +68,7 @@ Init sem perguntas (CI):
|
|
|
61
68
|
--sem-hooks não instala os hooks do harness
|
|
62
69
|
--sem-agents não instala os agents somente leitura
|
|
63
70
|
--sem-comentarios não instala a rule, o hook e a catraca de comentários
|
|
71
|
+
--sem-banco-dados não registra o servidor MCP de banco no .mcp.json
|
|
64
72
|
--claude-md criar|manter|backup|sobrescrever (padrão: criar)
|
|
65
73
|
--conflito sobrescrever|pular (padrão: pular)
|
|
66
74
|
--yes aplica; sem ele o init só simula
|
|
@@ -146,6 +154,15 @@ async function main() {
|
|
|
146
154
|
const { runConflictsCommand } = await import('./trabalhos/conflitos.js');
|
|
147
155
|
process.exit(await runConflictsCommand(argv.slice(1)));
|
|
148
156
|
}
|
|
157
|
+
if (cmd === 'banco-dados') {
|
|
158
|
+
const { runBancoCommand } = await import('./banco-dados/command.js');
|
|
159
|
+
process.exit(await runBancoCommand(argv.slice(1)));
|
|
160
|
+
}
|
|
161
|
+
if (cmd === 'banco-dados-mcp') {
|
|
162
|
+
const { iniciarServidorMcp } = await import('./banco-dados/mcp.js');
|
|
163
|
+
await iniciarServidorMcp({ entrada: process.stdin, saida: process.stdout });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
149
166
|
if (cmd === 'update') {
|
|
150
167
|
const { runUpdateCommand } = await import('./update/update.js');
|
|
151
168
|
process.exit(runUpdateCommand(argv.slice(1)));
|
|
@@ -165,6 +182,11 @@ async function main() {
|
|
|
165
182
|
const { args, claudeFlags } = splitArgs(argv.slice(1));
|
|
166
183
|
process.exit(runCommand(cmd, args, claudeFlags));
|
|
167
184
|
}
|
|
185
|
+
const renamed = RENAMED_COMMANDS[cmd];
|
|
186
|
+
if (renamed) {
|
|
187
|
+
console.error(`O comando ${cmd} mudou de nome: use ${renamed}.`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
168
190
|
if (!RESERVED.has(cmd)) {
|
|
169
191
|
const near = suggestIds(cmd.replace(/^\//, ''), ids);
|
|
170
192
|
console.error(`Comando desconhecido: ${cmd}`);
|
package/dist/config/config.js
CHANGED
|
@@ -11,17 +11,190 @@ export const DEFAULT_HOOK_MODES = {
|
|
|
11
11
|
'proteger-contratos': 'aviso',
|
|
12
12
|
'sem-jargao': 'aviso',
|
|
13
13
|
};
|
|
14
|
+
export const MODOS_PRODUCAO = ['aviso', 'bloqueio'];
|
|
15
|
+
export const TIPOS_CONEXAO_PROJETO = ['postgres', 'mariadb', 'sqlite'];
|
|
14
16
|
function isObject(value) {
|
|
15
17
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
18
|
}
|
|
19
|
+
/** Seção do banco no config; até a 2.7.0 ela se chamava `banco`. */
|
|
20
|
+
export const BANCO_CHAVE = 'banco_dados';
|
|
21
|
+
export const BANCO_CHAVE_LEGADA = 'banco';
|
|
22
|
+
/** Só a seção com o nome antigo existe: ela vale e o update a renomeia. */
|
|
23
|
+
function soSecaoLegada(raw) {
|
|
24
|
+
return !(BANCO_CHAVE in raw) && BANCO_CHAVE_LEGADA in raw;
|
|
25
|
+
}
|
|
26
|
+
/** A seção do banco no JSON bruto: `banco_dados`, ou a `banco` antiga quando só ela existe. */
|
|
27
|
+
export function secaoBanco(raw) {
|
|
28
|
+
const secao = soSecaoLegada(raw) ? raw[BANCO_CHAVE_LEGADA] : raw[BANCO_CHAVE];
|
|
29
|
+
return isObject(secao) ? secao : {};
|
|
30
|
+
}
|
|
31
|
+
/** O JSON com a seção do banco gravada como `banco_dados`; a `banco` antiga é trocada na mesma posição. */
|
|
32
|
+
export function comSecaoBanco(raw, secao) {
|
|
33
|
+
if (!soSecaoLegada(raw))
|
|
34
|
+
return { ...raw, [BANCO_CHAVE]: secao };
|
|
35
|
+
return Object.fromEntries(Object.entries(raw).map(([chave, valor]) => (chave === BANCO_CHAVE_LEGADA ? [BANCO_CHAVE, secao] : [chave, valor])));
|
|
36
|
+
}
|
|
37
|
+
function lerJsonBruto(root) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(join(root, CONFIG_PATH), 'utf8'));
|
|
40
|
+
return isObject(parsed) ? parsed : undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** O config ainda tem a seção do banco com o nome antigo (`banco`), que o update renomeia. */
|
|
47
|
+
export function temSecaoBancoLegada(root) {
|
|
48
|
+
const raw = lerJsonBruto(root);
|
|
49
|
+
return raw !== undefined && soSecaoLegada(raw);
|
|
50
|
+
}
|
|
51
|
+
/** Renomeia a seção `banco` para `banco_dados`, preservando o resto do JSON; devolve se mudou. */
|
|
52
|
+
export function migrarSecaoBanco(root) {
|
|
53
|
+
const raw = lerJsonBruto(root);
|
|
54
|
+
if (!raw || !soSecaoLegada(raw))
|
|
55
|
+
return false;
|
|
56
|
+
writeFileSync(join(root, CONFIG_PATH), `${JSON.stringify(comSecaoBanco(raw, secaoBanco(raw)), null, 2)}\n`);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
17
59
|
export function defaultConfig() {
|
|
18
60
|
return {
|
|
19
61
|
config_versao: CONFIG_VERSION,
|
|
20
62
|
hooks: { ...DEFAULT_HOOK_MODES },
|
|
21
63
|
rastro: { versionar: false },
|
|
22
64
|
bloqueios: { dias_para_aviso: 7 },
|
|
65
|
+
banco_dados: {
|
|
66
|
+
conexoes: [],
|
|
67
|
+
producao: 'bloqueio',
|
|
68
|
+
limite_linhas: 200,
|
|
69
|
+
timeout_segundos: 30,
|
|
70
|
+
registrar_mcp: true,
|
|
71
|
+
lgpd: { mascarar_colunas: [], liberar_colunas: [] },
|
|
72
|
+
projeto: {},
|
|
73
|
+
declarado: false,
|
|
74
|
+
},
|
|
23
75
|
};
|
|
24
76
|
}
|
|
77
|
+
function inteiroEntre(value, min, max) {
|
|
78
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
|
|
79
|
+
}
|
|
80
|
+
const SO_NO_CADASTRO = 'O mascaramento LGPD só pode ser desligado no cadastro da máquina (mascaramento: false na conexão)';
|
|
81
|
+
const REGRA_DE_COLUNA = /^[^.\s]+(\.[^.\s]+){0,2}$/;
|
|
82
|
+
function lerLgpd(raw, lgpd, problems) {
|
|
83
|
+
if (!isObject(raw)) {
|
|
84
|
+
problems.push(`banco_dados.lgpd deve ser um objeto com mascarar_colunas e liberar_colunas em ${CONFIG_PATH}.`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if ('desligar' in raw)
|
|
88
|
+
problems.push(`${SO_NO_CADASTRO}; banco_dados.lgpd.desligar em ${CONFIG_PATH} foi ignorado.`);
|
|
89
|
+
for (const chave of ['mascarar_colunas', 'liberar_colunas']) {
|
|
90
|
+
if (!(chave in raw))
|
|
91
|
+
continue;
|
|
92
|
+
const lista = raw[chave];
|
|
93
|
+
if (!Array.isArray(lista)) {
|
|
94
|
+
problems.push(`banco_dados.lgpd.${chave} deve ser uma lista de colunas em ${CONFIG_PATH}.`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
lgpd[chave] = lista.filter((regra) => {
|
|
98
|
+
if (typeof regra === 'string' && REGRA_DE_COLUNA.test(regra))
|
|
99
|
+
return true;
|
|
100
|
+
problems.push(`Entrada ${JSON.stringify(regra)} ignorada em banco_dados.lgpd.${chave} de ${CONFIG_PATH}; use coluna, tabela.coluna ou schema.tabela.coluna.`);
|
|
101
|
+
return false;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export const REGRA_NOME_CONEXAO = /^[a-z0-9][a-z0-9_-]*$/;
|
|
106
|
+
export const REGRA_DSN_ENV = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
107
|
+
const CHAVES_CONEXAO_PROJETO = ['nome', 'dsn_env', 'tipo', 'producao'];
|
|
108
|
+
/** Devolve o motivo de a conexão ser inválida, ou null se ela puder ser usada. */
|
|
109
|
+
function motivoConexaoInvalida(entrada) {
|
|
110
|
+
if (typeof entrada.nome !== 'string' || !REGRA_NOME_CONEXAO.test(entrada.nome)) {
|
|
111
|
+
return 'nome deve usar só minúsculas, dígitos, - e _, começando por minúscula ou dígito';
|
|
112
|
+
}
|
|
113
|
+
if (typeof entrada.dsn_env !== 'string' || !REGRA_DSN_ENV.test(entrada.dsn_env)) {
|
|
114
|
+
return 'dsn_env deve ser um nome de variável de ambiente (letras, dígitos e _, sem começar por dígito)';
|
|
115
|
+
}
|
|
116
|
+
if ('tipo' in entrada && !TIPOS_CONEXAO_PROJETO.includes(entrada.tipo)) {
|
|
117
|
+
return 'tipo deve ser postgres, mariadb ou sqlite';
|
|
118
|
+
}
|
|
119
|
+
if ('producao' in entrada && typeof entrada.producao !== 'boolean')
|
|
120
|
+
return 'producao deve ser true ou false';
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
function lerConexoes(conexoes, banco, problems) {
|
|
124
|
+
banco.declarado = !(Array.isArray(conexoes) && conexoes.length === 0);
|
|
125
|
+
if (!Array.isArray(conexoes)) {
|
|
126
|
+
problems.push(`banco_dados.conexoes deve ser uma lista de nomes ou de objetos { nome, dsn_env } em ${CONFIG_PATH}.`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const entrada of conexoes) {
|
|
130
|
+
const citada = JSON.stringify(entrada);
|
|
131
|
+
let nome;
|
|
132
|
+
if (typeof entrada === 'string' && entrada.trim() !== '')
|
|
133
|
+
nome = entrada;
|
|
134
|
+
else if (isObject(entrada)) {
|
|
135
|
+
const motivo = motivoConexaoInvalida(entrada);
|
|
136
|
+
if (motivo) {
|
|
137
|
+
problems.push(`Entrada ${citada} descartada em banco_dados.conexoes de ${CONFIG_PATH}: ${motivo}.`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
nome = entrada.nome;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
problems.push(`Entrada ${citada} descartada em banco_dados.conexoes de ${CONFIG_PATH}; use um nome (texto não vazio) ou um objeto { nome, dsn_env }.`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (banco.conexoes.includes(nome)) {
|
|
147
|
+
problems.push(`Entrada ${citada} descartada em banco_dados.conexoes de ${CONFIG_PATH}: o nome "${nome}" já foi declarado antes.`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
banco.conexoes.push(nome);
|
|
151
|
+
if (typeof entrada === 'string' || !isObject(entrada))
|
|
152
|
+
continue;
|
|
153
|
+
for (const chave of Object.keys(entrada).filter((chave) => !CHAVES_CONEXAO_PROJETO.includes(chave))) {
|
|
154
|
+
problems.push(chave === 'mascaramento'
|
|
155
|
+
? `${SO_NO_CADASTRO}; mascaramento da entrada ${citada} em banco_dados.conexoes de ${CONFIG_PATH} foi ignorado.`
|
|
156
|
+
: `Chave desconhecida ${chave} ignorada na entrada ${citada} de banco_dados.conexoes em ${CONFIG_PATH}.`);
|
|
157
|
+
}
|
|
158
|
+
banco.projeto[nome] = {
|
|
159
|
+
nome,
|
|
160
|
+
dsn_env: entrada.dsn_env,
|
|
161
|
+
...(entrada.tipo === undefined ? {} : { tipo: entrada.tipo }),
|
|
162
|
+
producao: entrada.producao === true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function lerBanco(raw, banco, problems) {
|
|
167
|
+
if ('conexoes' in raw)
|
|
168
|
+
lerConexoes(raw.conexoes, banco, problems);
|
|
169
|
+
if ('producao' in raw) {
|
|
170
|
+
if (MODOS_PRODUCAO.includes(raw.producao))
|
|
171
|
+
banco.producao = raw.producao;
|
|
172
|
+
else
|
|
173
|
+
problems.push(`Modo "${String(raw.producao)}" inválido para banco_dados.producao em ${CONFIG_PATH}; use aviso ou bloqueio.`);
|
|
174
|
+
}
|
|
175
|
+
if ('limite_linhas' in raw) {
|
|
176
|
+
if (inteiroEntre(raw.limite_linhas, 1, 5000))
|
|
177
|
+
banco.limite_linhas = raw.limite_linhas;
|
|
178
|
+
else
|
|
179
|
+
problems.push(`banco_dados.limite_linhas deve ser um inteiro de 1 a 5000 em ${CONFIG_PATH}.`);
|
|
180
|
+
}
|
|
181
|
+
if ('timeout_segundos' in raw) {
|
|
182
|
+
if (inteiroEntre(raw.timeout_segundos, 1, 600))
|
|
183
|
+
banco.timeout_segundos = raw.timeout_segundos;
|
|
184
|
+
else
|
|
185
|
+
problems.push(`banco_dados.timeout_segundos deve ser um inteiro de 1 a 600 em ${CONFIG_PATH}.`);
|
|
186
|
+
}
|
|
187
|
+
if ('registrar_mcp' in raw) {
|
|
188
|
+
if (typeof raw.registrar_mcp === 'boolean')
|
|
189
|
+
banco.registrar_mcp = raw.registrar_mcp;
|
|
190
|
+
else
|
|
191
|
+
problems.push(`banco_dados.registrar_mcp deve ser true ou false em ${CONFIG_PATH}.`);
|
|
192
|
+
}
|
|
193
|
+
if ('mascaramento' in raw)
|
|
194
|
+
problems.push(`${SO_NO_CADASTRO}; banco_dados.mascaramento em ${CONFIG_PATH} foi ignorado.`);
|
|
195
|
+
if ('lgpd' in raw)
|
|
196
|
+
lerLgpd(raw.lgpd, banco.lgpd, problems);
|
|
197
|
+
}
|
|
25
198
|
/** Lê a config sobre os padrões; valores inválidos são ignorados e viram problema, nunca erro. */
|
|
26
199
|
export function readConfig(root) {
|
|
27
200
|
const config = defaultConfig();
|
|
@@ -59,17 +232,95 @@ export function readConfig(root) {
|
|
|
59
232
|
else
|
|
60
233
|
problems.push(`bloqueios.dias_para_aviso deve ser um inteiro positivo em ${CONFIG_PATH}.`);
|
|
61
234
|
}
|
|
235
|
+
const secao = soSecaoLegada(raw) ? raw[BANCO_CHAVE_LEGADA] : raw[BANCO_CHAVE];
|
|
236
|
+
if (isObject(secao))
|
|
237
|
+
lerBanco(secao, config.banco_dados, problems);
|
|
238
|
+
if (soSecaoLegada(raw)) {
|
|
239
|
+
problems.push(`A seção banco de ${CONFIG_PATH} agora se chama banco_dados; rode npx @fontdata/ai-harness update --yes para renomeá-la.`);
|
|
240
|
+
}
|
|
241
|
+
else if (BANCO_CHAVE_LEGADA in raw) {
|
|
242
|
+
problems.push(`A seção banco de ${CONFIG_PATH} foi ignorada: vale a banco_dados. Apague a banco.`);
|
|
243
|
+
}
|
|
62
244
|
return { config, problems };
|
|
63
245
|
}
|
|
64
|
-
/** Cria a config com os padrões; retorna false se já existia (nunca sobrescreve). */
|
|
246
|
+
/** Cria a config com os padrões, sem a seção banco_dados; retorna false se já existia (nunca sobrescreve). */
|
|
65
247
|
export function writeDefaultConfig(root) {
|
|
66
248
|
const file = join(root, CONFIG_PATH);
|
|
67
249
|
if (existsSync(file))
|
|
68
250
|
return false;
|
|
69
251
|
mkdirSync(dirname(file), { recursive: true });
|
|
70
|
-
|
|
252
|
+
const { banco_dados: _banco, ...gravada } = defaultConfig();
|
|
253
|
+
writeFileSync(file, `${JSON.stringify(gravada, null, 2)}\n`);
|
|
71
254
|
return true;
|
|
72
255
|
}
|
|
256
|
+
function lerJsonParaEscrita(file, chave) {
|
|
257
|
+
let parsed;
|
|
258
|
+
try {
|
|
259
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
parsed = undefined;
|
|
263
|
+
}
|
|
264
|
+
if (!isObject(parsed))
|
|
265
|
+
throw new Error(`O ${CONFIG_PATH} está com JSON inválido; corrija-o antes de gravar ${chave}.`);
|
|
266
|
+
return parsed;
|
|
267
|
+
}
|
|
268
|
+
/** Lança o mesmo erro de `gravarConexaoProjeto` se a config existir com JSON inválido; nunca escreve. */
|
|
269
|
+
export function validarConfigParaEscrita(root) {
|
|
270
|
+
const file = join(root, CONFIG_PATH);
|
|
271
|
+
if (existsSync(file))
|
|
272
|
+
lerJsonParaEscrita(file, 'banco_dados.conexoes');
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Grava a conexão em `banco_dados.conexoes`, preservando o resto do JSON; sem config, cria uma com os padrões.
|
|
276
|
+
* Se o nome já existir (objeto ou string), lança, ou troca a entrada na mesma posição com `substituir`.
|
|
277
|
+
* JSON inválido é recusado sem tocar no arquivo.
|
|
278
|
+
*/
|
|
279
|
+
export function gravarConexaoProjeto(root, conexao, { substituir = false } = {}) {
|
|
280
|
+
const motivo = motivoConexaoInvalida({ ...conexao });
|
|
281
|
+
if (motivo)
|
|
282
|
+
throw new Error(`Conexão ${JSON.stringify(conexao.nome)} inválida: ${motivo}.`);
|
|
283
|
+
const file = join(root, CONFIG_PATH);
|
|
284
|
+
const raw = existsSync(file) ? lerJsonParaEscrita(file, 'banco_dados.conexoes') : undefined;
|
|
285
|
+
const banco = raw ? secaoBanco(raw) : {};
|
|
286
|
+
const conexoes = banco.conexoes === undefined ? [] : banco.conexoes;
|
|
287
|
+
if (!Array.isArray(conexoes))
|
|
288
|
+
throw new Error(`banco_dados.conexoes em ${CONFIG_PATH} não é uma lista; corrija-o antes de gravar a conexão ${conexao.nome}.`);
|
|
289
|
+
const posicao = conexoes.findIndex((entrada) => (isObject(entrada) ? entrada.nome : entrada) === conexao.nome);
|
|
290
|
+
if (posicao !== -1 && !substituir)
|
|
291
|
+
throw new Error(`A conexão ${conexao.nome} já está em banco_dados.conexoes de ${CONFIG_PATH}.`);
|
|
292
|
+
const entrada = {
|
|
293
|
+
nome: conexao.nome,
|
|
294
|
+
dsn_env: conexao.dsn_env,
|
|
295
|
+
...(conexao.tipo === undefined ? {} : { tipo: conexao.tipo }),
|
|
296
|
+
...(conexao.producao === true ? { producao: true } : {}),
|
|
297
|
+
};
|
|
298
|
+
const proximas = posicao === -1 ? [...conexoes, entrada] : conexoes.map((atual, i) => (i === posicao ? entrada : atual));
|
|
299
|
+
if (!raw)
|
|
300
|
+
writeDefaultConfig(root);
|
|
301
|
+
const base = raw ?? lerJsonParaEscrita(file, 'banco_dados.conexoes');
|
|
302
|
+
writeFileSync(file, `${JSON.stringify(comSecaoBanco(base, { ...banco, conexoes: proximas }), null, 2)}\n`);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Grava só `banco_dados.registrar_mcp`, preservando o resto do JSON; sem config, cria uma com os padrões
|
|
306
|
+
* e essa chave. JSON inválido é recusado sem tocar no arquivo.
|
|
307
|
+
*/
|
|
308
|
+
export function gravarRegistrarMcp(root, valor) {
|
|
309
|
+
const file = join(root, CONFIG_PATH);
|
|
310
|
+
let raw;
|
|
311
|
+
if (existsSync(file)) {
|
|
312
|
+
raw = lerJsonParaEscrita(file, 'banco_dados.registrar_mcp');
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
const { banco_dados: _banco, ...padrao } = defaultConfig();
|
|
316
|
+
raw = padrao;
|
|
317
|
+
}
|
|
318
|
+
const banco = secaoBanco(raw);
|
|
319
|
+
if (banco.registrar_mcp === valor)
|
|
320
|
+
return;
|
|
321
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
322
|
+
writeFileSync(file, `${JSON.stringify(comSecaoBanco(raw, { ...banco, registrar_mcp: valor }), null, 2)}\n`);
|
|
323
|
+
}
|
|
73
324
|
const BLOCK_START = '# ai-harness (gerenciado pelo harness; não edite entre estas linhas)';
|
|
74
325
|
const BLOCK_END = '# /ai-harness';
|
|
75
326
|
/**
|