@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.
@@ -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}`);
@@ -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
- writeFileSync(file, `${JSON.stringify(defaultConfig(), null, 2)}\n`);
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
  /**