@fontdata/ai-harness 3.0.0 → 3.1.1

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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "fontdata",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "mcpServers": {
5
- "banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@3.0.0", "banco-dados-mcp"] }
5
+ "banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@3.1.1", "banco-dados-mcp"] }
6
6
  },
7
7
  "description": "Harness de engenharia com IA da Fontdata para Claude Code",
8
8
  "author": { "name": "Fontdata" },
package/README.md CHANGED
@@ -129,15 +129,16 @@ da conexão e o nome da variável de ambiente (`dsn_env`), e cada dev guarda o D
129
129
  `.claude/settings.local.json` (fora do git), no `.env` ou no ambiente:
130
130
 
131
131
  ```bash
132
- npx @fontdata/ai-harness banco-dados adicionar erp --projeto # pergunta os dados, testa e grava ERP_DATABASE_URL
132
+ npx @fontdata/ai-harness banco-dados adicionar erp # na raiz do projeto: pergunta os dados, testa e grava ERP_DATABASE_URL
133
133
  npx @fontdata/ai-harness banco-dados testar
134
134
  ```
135
135
 
136
- O `adicionar --projeto` grava `{ "nome": "erp", "dsn_env": "ERP_DATABASE_URL" }` em `banco_dados.conexoes` e a variável
137
- no `.claude/settings.local.json`. Faça commit do `config.json`; cada colega roda o mesmo comando, que grava só a
138
- variável. `"producao": true` na entrada vale para a equipe toda. Quem prefere um cadastro por máquina
139
- (`~/.config/ai-harness/conexoes.json`) usa `banco-dados adicionar erp-dev` sem `--projeto` e `banco-dados usar erp-dev` no
140
- projeto.
136
+ O `adicionar` grava `{ "nome": "erp", "dsn_env": "ERP_DATABASE_URL" }` em `banco_dados.conexoes` e a variável no
137
+ `.claude/settings.local.json`; fora da raiz de um projeto (sem `.git` nem `.ai-harness/config.json`), ele recusa sem
138
+ gravar nada. Faça commit do `config.json`; cada colega roda o mesmo comando, que grava só a variável.
139
+ `"producao": true` na entrada vale para a equipe toda, e `banco-dados remover erp` tira a conexão do projeto. Quem
140
+ prefere um cadastro por máquina (`~/.config/ai-harness/conexoes.json`) usa `banco-dados adicionar erp-dev --global` e
141
+ `banco-dados usar erp-dev` no projeto.
141
142
 
142
143
  O servidor MCP `banco_dados` entrega ao Claude `listar_conexoes`, `listar_tabelas`, `descrever_tabela` e
143
144
  `consultar`, tudo somente leitura. Ele vem no plugin; sem plugin, o `init` e o `update` o registram no `.mcp.json`
@@ -81,12 +81,27 @@ export function gravarVariavelSettingsLocal(raiz, nome, valor) {
81
81
  const envAtual = ehObjeto(dados.env) ? dados.env : {};
82
82
  if (envAtual[nome] === valor)
83
83
  return { changed: false };
84
- const novo = { ...dados, env: { ...envAtual, [nome]: valor } };
85
84
  const modo = leitura.existe ? statSync(caminho).mode & 0o777 : 0o600;
86
85
  mkdirSync(dirname(caminho), { recursive: true });
86
+ gravarAtomico(caminho, { ...dados, env: { ...envAtual, [nome]: valor } }, modo);
87
+ return { changed: true };
88
+ }
89
+ /** Tira a variável do env do settings.local.json com a mesma gravação atômica; arquivo ou chave ausente não escreve. */
90
+ export function removerVariavelSettingsLocal(raiz, nome) {
91
+ const caminho = join(raiz, SETTINGS_LOCAL_PATH);
92
+ const leitura = lerSettings(caminho);
93
+ if ('erro' in leitura)
94
+ throw new Error(leitura.erro);
95
+ if (!leitura.existe || !ehObjeto(leitura.dados.env) || !Object.hasOwn(leitura.dados.env, nome))
96
+ return { changed: false };
97
+ const { [nome]: _removida, ...envRestante } = leitura.dados.env;
98
+ gravarAtomico(caminho, { ...leitura.dados, env: envRestante }, statSync(caminho).mode & 0o777);
99
+ return { changed: true };
100
+ }
101
+ function gravarAtomico(caminho, dados, modo) {
87
102
  const temporario = `${caminho}.${process.pid}.${Date.now()}.tmp`;
88
103
  try {
89
- writeFileSync(temporario, `${JSON.stringify(novo, null, 2)}\n`, { mode: modo });
104
+ writeFileSync(temporario, `${JSON.stringify(dados, null, 2)}\n`, { mode: modo });
90
105
  chmodSync(temporario, modo);
91
106
  renameSync(temporario, caminho);
92
107
  }
@@ -94,5 +109,4 @@ export function gravarVariavelSettingsLocal(raiz, nome, valor) {
94
109
  rmSync(temporario, { force: true });
95
110
  throw erro;
96
111
  }
97
- return { changed: true };
98
112
  }
@@ -2,24 +2,28 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { EOL } from 'node:os';
3
3
  import { join, resolve } from 'node:path';
4
4
  import * as p from '@clack/prompts';
5
- import { comSecaoBanco, CONFIG_PATH, gravarConexaoProjeto, readConfig, REGRA_DSN_ENV, REGRA_NOME_CONEXAO, secaoBanco, validarConfigParaEscrita, writeDefaultConfig, } from '../config/config.js';
5
+ import { comSecaoBanco, CONFIG_PATH, gravarConexaoProjeto, readConfig, removerConexaoProjeto, REGRA_DSN_ENV, REGRA_NOME_CONEXAO, secaoBanco, validarConfigParaEscrita, writeDefaultConfig, } from '../config/config.js';
6
6
  import { suggestFlag } from '../install/flags.js';
7
7
  import { caminhoCadastro, lerCadastro, ocultarSenha, removerConexao, resumirConexao, salvarConexao, TIPOS_BANCO, } from './cadastro.js';
8
- import { gravarVariavelSettingsLocal, lerEnvSettingsLocal, lerVariavel, SETTINGS_LOCAL_PATH, validarSettingsLocal } from './ambiente.js';
8
+ import { gravarVariavelSettingsLocal, lerEnvSettingsLocal, lerVariavel, removerVariavelSettingsLocal, SETTINGS_LOCAL_PATH, validarSettingsLocal, } from './ambiente.js';
9
9
  import { interpretarDsn, resolverConexoes } from './conexao.js';
10
10
  import { criarDriver as criarDriverPadrao } from './drivers/index.js';
11
11
  const AJUDA = [
12
12
  'Uso: ai-harness banco-dados <subcomando>',
13
13
  '',
14
- ' adicionar <nome> [--dsn <dsn>] [--producao] [--sem-testar] [--substituir]',
15
- ' cadastra a conexão nesta máquina (sem --dsn, pergunta os dados)',
16
- ' adicionar <nome> --projeto [--env <VAR>] [--dsn <dsn>] [--producao] [--sem-testar] [--substituir]',
17
- ' grava o DSN na variável <NOME>_DATABASE_URL (ou --env) do .claude/settings.local.json',
14
+ ' adicionar <nome> [--env <VAR>] [--dsn <dsn>] [--producao] [--sem-testar] [--substituir]',
15
+ ' grava no projeto: o DSN na variável <NOME>_DATABASE_URL (ou --env) do .claude/settings.local.json',
18
16
  ' e a conexão { nome, dsn_env } em banco_dados.conexoes do .ai-harness/config.json',
17
+ ' (sem --dsn, pergunta os dados)',
18
+ ' adicionar <nome> --global [--dsn <dsn>] [--producao] [--sem-testar] [--substituir]',
19
+ ' cadastra a conexão nesta máquina',
19
20
  ' listar mostra as conexões cadastradas e as do projeto, sem a senha',
20
21
  ' testar [nome] testa a conexão pedida ou todas as do projeto',
21
- ' usar <nome...> grava banco_dados.conexoes no .ai-harness/config.json (mantém as entradas { nome, dsn_env })',
22
- ' remover <nome> apaga a conexão do cadastro',
22
+ ' usar <nome...> grava banco_dados.conexoes no .ai-harness/config.json, para conexões do cadastro global',
23
+ ' (mantém as entradas { nome, dsn_env })',
24
+ ' remover <nome> [--global]',
25
+ ' tira a conexão do projeto e a variável dela do .claude/settings.local.json;',
26
+ ' com --global, apaga a conexão do cadastro desta máquina',
23
27
  ].join(EOL);
24
28
  const PORTA_PADRAO = { postgres: '5432', mariadb: '3306' };
25
29
  const ESQUEMA = { postgres: 'postgres', mariadb: 'mysql' };
@@ -118,14 +122,15 @@ function conexaoDoDsn(dsn, cwd, producao) {
118
122
  return conexao;
119
123
  }
120
124
  async function adicionar(ctx, argv) {
121
- const { posicionais, marcadas, valores } = lerArgumentos(argv, ['--producao', '--sem-testar', '--substituir', '--projeto'], ['--dsn', '--env']);
125
+ const { posicionais, marcadas, valores } = lerArgumentos(argv, ['--producao', '--sem-testar', '--substituir', '--global'], ['--dsn', '--env']);
122
126
  const [nome, ...sobra] = posicionais;
123
- if (!nome || sobra.length > 0)
124
- throw new Error('Uso: ai-harness banco-dados adicionar <nome> [--projeto [--env <VAR>]] [--dsn <dsn>] [--producao] [--sem-testar] [--substituir]');
125
- if (marcadas.has('--projeto'))
127
+ if (!nome || sobra.length > 0) {
128
+ throw new Error('Uso: ai-harness banco-dados adicionar <nome> [--global | --env <VAR>] [--dsn <dsn>] [--producao] [--sem-testar] [--substituir]');
129
+ }
130
+ if (!marcadas.has('--global'))
126
131
  return adicionarNoProjeto(ctx, nome, marcadas, valores);
127
132
  if (valores['--env'] !== undefined)
128
- throw new Error('--env só vale com --projeto.');
133
+ throw new Error('--env não vale com --global.');
129
134
  const substituir = marcadas.has('--substituir');
130
135
  if (!substituir && nome in lerCadastro(ctx.cadastro).conexoes) {
131
136
  ctx.erro(`A conexão ${nome} já existe em ${ctx.cadastro}. Use --substituir para trocá-la.`);
@@ -169,6 +174,10 @@ function variavelPadrao(nome) {
169
174
  return `${nome.toUpperCase().replace(/-/g, '_')}_DATABASE_URL`;
170
175
  }
171
176
  async function adicionarNoProjeto(ctx, nome, marcadas, valores) {
177
+ if (!existsSync(join(ctx.cwd, '.git')) && !existsSync(join(ctx.cwd, CONFIG_PATH))) {
178
+ ctx.erro(`Nenhum projeto em ${ctx.cwd} (sem .git nem ${CONFIG_PATH}). Rode na raiz do projeto ou use --global para cadastrar nesta máquina.`);
179
+ return 1;
180
+ }
172
181
  const substituir = marcadas.has('--substituir');
173
182
  if (!REGRA_NOME_CONEXAO.test(nome)) {
174
183
  ctx.erro(`Nome de conexão inválido: ${JSON.stringify(nome)}; use só minúsculas, dígitos, - e _, começando por minúscula ou dígito.`);
@@ -260,7 +269,7 @@ function listar(ctx, argv) {
260
269
  const doProjeto = config.banco_dados.conexoes.flatMap((nome) => config.banco_dados.projeto[nome] ?? []);
261
270
  const nomes = Object.keys(conexoes).sort();
262
271
  if (nomes.length === 0 && doProjeto.length === 0) {
263
- ctx.log(`Nenhuma conexão cadastrada em ${ctx.cadastro}. Cadastre com: npx @fontdata/ai-harness banco-dados adicionar <nome>`);
272
+ ctx.log('Nenhuma conexão. Na raiz do projeto, rode: npx @fontdata/ai-harness banco-dados adicionar <nome>');
264
273
  return 0;
265
274
  }
266
275
  for (const nome of nomes) {
@@ -313,7 +322,7 @@ async function testar(ctx, argv) {
313
322
  for (const problema of resolvidas.problemas)
314
323
  ctx.erro(problema);
315
324
  for (const faltando of resolvidas.faltando.filter((item) => nome === undefined || item === nome)) {
316
- ctx.erro(`Conexão ${faltando} não encontrada nesta máquina; rode banco-dados adicionar ${faltando}.`);
325
+ ctx.erro(`Conexão ${faltando} não encontrada nesta máquina; rode banco-dados adicionar ${faltando} --global.`);
317
326
  ok = false;
318
327
  }
319
328
  for (const ausente of resolvidas.variaveisAusentes.filter(pedida)) {
@@ -327,7 +336,7 @@ async function testar(ctx, argv) {
327
336
  }
328
337
  alvos = resolvidas.conexoes.filter(pedida);
329
338
  if (alvos.length === 0 && ok) {
330
- ctx.erro('Nenhuma conexão de banco configurada neste projeto; rode npx @fontdata/ai-harness banco-dados usar <nome>.');
339
+ ctx.erro('Nenhuma conexão de banco configurada neste projeto; rode npx @fontdata/ai-harness banco-dados adicionar <nome>.');
331
340
  return 1;
332
341
  }
333
342
  }
@@ -371,15 +380,17 @@ function usar(ctx, argv) {
371
380
  const { conexoes } = lerCadastro(ctx.cadastro);
372
381
  const ausentes = unicos.filter((nome) => !(nome in conexoes) && !objetos.has(nome));
373
382
  if (ausentes.length > 0) {
374
- ctx.erro(`Aviso: sem cadastro nesta máquina para ${ausentes.join(', ')}; rode banco-dados adicionar <nome> para cada uma.`);
383
+ ctx.erro(`Aviso: sem cadastro nesta máquina para ${ausentes.join(', ')}; rode banco-dados adicionar <nome> --global para cada uma.`);
375
384
  }
376
385
  return 0;
377
386
  }
378
387
  function remover(ctx, argv) {
379
- const { posicionais } = lerArgumentos(argv, []);
388
+ const { posicionais, marcadas } = lerArgumentos(argv, ['--global']);
380
389
  const [nome, ...sobra] = posicionais;
381
390
  if (!nome || sobra.length > 0)
382
- throw new Error('Uso: ai-harness banco-dados remover <nome>');
391
+ throw new Error('Uso: ai-harness banco-dados remover <nome> [--global]');
392
+ if (!marcadas.has('--global'))
393
+ return removerDoProjeto(ctx, nome);
383
394
  if (!removerConexao(nome, ctx.cadastro)) {
384
395
  ctx.erro(`Conexão ${nome} não existe em ${ctx.cadastro}.`);
385
396
  return 1;
@@ -387,7 +398,34 @@ function remover(ctx, argv) {
387
398
  ctx.log(`Conexão ${nome} removida de ${ctx.cadastro}.`);
388
399
  return 0;
389
400
  }
390
- /** `ai-harness banco-dados <subcomando>`: cadastro de conexões da máquina e escolha das conexões do projeto. */
401
+ function removerDoProjeto(ctx, nome) {
402
+ validarSettingsLocal(ctx.cwd);
403
+ validarConfigParaEscrita(ctx.cwd);
404
+ const removida = removerConexaoProjeto(ctx.cwd, nome);
405
+ if (removida === null) {
406
+ const noCadastro = nome in lerCadastro(ctx.cadastro).conexoes;
407
+ ctx.erro(`Conexão ${nome} não está em banco_dados.conexoes de ${CONFIG_PATH}.${noCadastro ? ` Para o cadastro desta máquina, use remover ${nome} --global.` : ''}`);
408
+ return 1;
409
+ }
410
+ ctx.log(`Conexão ${nome} removida de banco_dados.conexoes de ${CONFIG_PATH}.`);
411
+ const variavel = typeof removida === 'string' ? undefined : removida.dsn_env;
412
+ if (variavel === undefined)
413
+ return 0;
414
+ const atual = lerEnvSettingsLocal(ctx.cwd)[variavel];
415
+ if (atual !== undefined)
416
+ ctx.sensiveis.push(sensivel(atual));
417
+ const outra = Object.values(readConfig(ctx.cwd).config.banco_dados.projeto).find((conexao) => conexao.dsn_env === variavel);
418
+ if (outra)
419
+ ctx.log(`Variável ${variavel} mantida em ${SETTINGS_LOCAL_PATH}: a conexão ${outra.nome} também a usa.`);
420
+ else if (removerVariavelSettingsLocal(ctx.cwd, variavel).changed)
421
+ ctx.log(`Variável ${variavel} removida de ${SETTINGS_LOCAL_PATH}.`);
422
+ ctx.log(`Se ${variavel} estiver definida no .env ou no ambiente, ela não foi tocada.`);
423
+ return 0;
424
+ }
425
+ /**
426
+ * `ai-harness banco-dados <subcomando>`: conexões do projeto (padrão), cadastro global de conexões da máquina
427
+ * (`--global`) e escolha das conexões do cadastro que o projeto usa.
428
+ */
391
429
  export async function runBancoCommand(argv, opcoes = {}) {
392
430
  const saida = opcoes.saida ?? { log: (texto) => console.log(texto), erro: (texto) => console.error(texto) };
393
431
  const cadastro = opcoes.caminhoCadastro ?? caminhoCadastro();
@@ -118,7 +118,7 @@ export function resolverConexoes(raiz, opcoes = {}) {
118
118
  }
119
119
  export function escolherConexao(resolvidas, nome) {
120
120
  if (resolvidas.length === 0) {
121
- throw new Error('Nenhuma conexão de banco configurada neste projeto; rode npx @fontdata/ai-harness banco-dados usar <nome>');
121
+ throw new Error('Nenhuma conexão de banco configurada neste projeto; rode npx @fontdata/ai-harness banco-dados adicionar <nome> na raiz do projeto');
122
122
  }
123
123
  const escolhida = nome === undefined ? resolvidas[0] : resolvidas.find((conexao) => conexao.nome === nome);
124
124
  if (!escolhida)
@@ -55,16 +55,15 @@ export const INSTRUCOES = [
55
55
  'Use estas ferramentas para ler o banco de dados do projeto; o acesso é somente leitura.',
56
56
  'Nunca peça nem repita credenciais, senhas ou DSNs no chat.',
57
57
  'As conexões vêm do cadastro da máquina ou do projeto: uma conexão do projeto é declarada em banco_dados.conexoes do .ai-harness/config.json com dsn_env (o nome da variável), e o valor da variável fica no .claude/settings.local.json, no .env ou no ambiente.',
58
- 'Sem conexão configurada, oriente o usuário a rodar no terminal `npx @fontdata/ai-harness banco-dados adicionar <nome>` e, no projeto, `npx @fontdata/ai-harness banco-dados usar <nome>`.',
58
+ 'Sem conexão configurada, oriente o usuário a rodar na raiz do projeto `npx @fontdata/ai-harness banco-dados adicionar <nome>`.',
59
59
  'Se a variável de uma conexão do projeto não estiver definida, oriente a defini-la no .claude/settings.local.json, no .env ou no ambiente.',
60
60
  'Antes de consultar, rode listar_tabelas e descrever_tabela para conhecer o esquema.',
61
61
  'Em consultar, selecione só as colunas necessárias e filtre as linhas; evite SELECT *.',
62
62
  'Somente leitura: comandos que alteram dados ou esquema são recusados.',
63
63
  'Os dados pessoais (CPF, e-mail, telefone, nome e afins) vêm mascarados, e o rodapé de consultar diz o que foi mascarado. Não tente contornar o mascaramento: não concatene, fatie, converta nem compare valores para reconstruir o original. Peça só as colunas necessárias.',
64
64
  ].join('\n');
65
- const ORIENTACAO_USAR = 'rode npx @fontdata/ai-harness banco-dados usar <nome> no projeto';
66
65
  function orientarAdicionar(nome) {
67
- return `Conexão ${nome} não encontrada nesta máquina; rode npx @fontdata/ai-harness banco-dados adicionar ${nome}`;
66
+ return `Conexão ${nome} não encontrada nesta máquina; rode npx @fontdata/ai-harness banco-dados adicionar ${nome} --global`;
68
67
  }
69
68
  function orientarVariavel(nome, variavel) {
70
69
  return `A variável ${variavel} da conexão ${nome} não está definida; defina-a no .claude/settings.local.json, no .env ou no ambiente`;
@@ -84,14 +83,23 @@ function paraJson(dados) {
84
83
  function plural(n, singular, plural) {
85
84
  return `${n} ${n === 1 ? singular : plural}`;
86
85
  }
87
- function rodapeLgpd({ colunasMascaradas, valoresEmTexto }) {
86
+ const MOTIVOS = {
87
+ nome: 'pelo nome',
88
+ projeto: 'pela regra do projeto',
89
+ sql: 'porque o SQL cita',
90
+ };
91
+ /** Diz, para cada coluna, a categoria e o motivo, para o usuário achar um falso positivo e liberá-lo no projeto. */
92
+ function rodapeLgpd({ colunasMascaradas, valoresEmTexto, detectados }) {
88
93
  const partes = [];
89
94
  if (colunasMascaradas.length > 0) {
90
- const lista = colunasMascaradas.map((c) => `${c.coluna} → ${c.categoria}`).join(', ');
95
+ const lista = colunasMascaradas
96
+ .map((c) => `${c.coluna} → ${c.categoria} ${MOTIVOS[c.motivo]}${c.citada ? ` ${c.citada}` : ''}`)
97
+ .join(', ');
91
98
  partes.push(`${plural(colunasMascaradas.length, 'coluna', 'colunas')} (${lista})`);
92
99
  }
93
100
  if (valoresEmTexto > 0) {
94
- partes.push(plural(valoresEmTexto, 'valor detectado pelo conteúdo', 'valores detectados pelo conteúdo'));
101
+ const lista = detectados.map((d) => `${d.coluna} → ${d.categorias.join('/')}`).join(', ');
102
+ partes.push(`${plural(valoresEmTexto, 'valor detectado pelo conteúdo', 'valores detectados pelo conteúdo')} (${lista})`);
95
103
  }
96
104
  return partes.length === 0 ? '' : `\n\nMascaramento LGPD: ${partes.join(' e ')}.`;
97
105
  }
@@ -173,7 +181,7 @@ async function executar(nome, args, contexto, banco, resolvidas) {
173
181
  if (descartadas.length > 0)
174
182
  dados.descartadas = descartadas;
175
183
  if (conexoes.length + faltando.length + variaveisAusentes.length + descartadas.length === 0) {
176
- dados.orientacao = `Nenhuma conexão configurada; rode npx @fontdata/ai-harness banco-dados adicionar <nome> e ${ORIENTACAO_USAR}`;
184
+ dados.orientacao = 'Nenhuma conexão configurada; rode npx @fontdata/ai-harness banco-dados adicionar <nome> na raiz do projeto';
177
185
  }
178
186
  if (resolvidas.problemas.length > 0)
179
187
  dados.problemas = resolvidas.problemas;
@@ -1,6 +1,19 @@
1
- import { identificadoresDoSql } from '../somente-leitura.js';
1
+ import { identificadoresDoSql, tokenizar } from '../somente-leitura.js';
2
2
  import { classificarColuna, classificarNumero, encontrarNoTexto } from './detectar.js';
3
3
  import { mascararValor, OCULTO } from './mascarar.js';
4
+ /** Categorias com formato próprio: uma flag (`'S'`, `true`, `0`) numa coluna dessas não carrega o dado pessoal. */
5
+ const CATEGORIAS_DE_FORMATO = new Set(['cpf', 'cnpj', 'documento', 'email', 'telefone', 'cep', 'cartao', 'ip']);
6
+ /** Palavras que encerram a lista de itens do `SELECT` quando aparecem fora de parênteses. */
7
+ const FIM_DOS_ITENS = new Set(['from', 'where', 'group', 'having', 'order', 'limit', 'offset', 'window', 'fetch', 'for']);
8
+ const INTEIRO_EM_TEXTO = /^\d+$/;
9
+ const NUMERO_EM_TEXTO = /^-?\d+(\.\d+)?$/;
10
+ function novoContador() {
11
+ return { valores: 0, categorias: new Set() };
12
+ }
13
+ function contar(contador, categoria) {
14
+ contador.valores += 1;
15
+ contador.categorias.add(categoria);
16
+ }
4
17
  function lerRegras(entradas) {
5
18
  return entradas
6
19
  .map((entrada) => entrada.trim().toLowerCase().split('.'))
@@ -21,20 +34,109 @@ function identificadores(sql, dialeto) {
21
34
  return Array.from(sql.matchAll(/[\p{L}_][\p{L}\p{N}_$]*/gu), (m) => m[0].toLowerCase());
22
35
  }
23
36
  }
24
- function colunaPessoalCitada(opcoes, mascarar) {
37
+ function ehContagem(tokens) {
38
+ if (tokens[0]?.tipo !== 'palavra' || tokens[0].valor !== 'count' || tokens[1]?.valor !== '(')
39
+ return false;
40
+ let profundidade = 0;
41
+ let fim = -1;
42
+ for (let i = 1; i < tokens.length; i++) {
43
+ if (tokens[i].valor === '(')
44
+ profundidade += 1;
45
+ else if (tokens[i].valor === ')' && --profundidade === 0) {
46
+ fim = i;
47
+ break;
48
+ }
49
+ }
50
+ if (fim === -1)
51
+ return false;
52
+ const resto = tokens.slice(fim + 1);
53
+ const apelido = (t) => t?.tipo === 'palavra' || t?.tipo === 'identificador-citado';
54
+ if (resto.length === 0)
55
+ return true;
56
+ if (resto.length === 1)
57
+ return apelido(resto[0]);
58
+ return resto.length === 2 && resto[0].tipo === 'palavra' && resto[0].valor === 'as' && apelido(resto[1]);
59
+ }
60
+ /**
61
+ * Identificadores de cada item do `SELECT`, na ordem das colunas. Só quando o SQL tem um único `SELECT` (sem `WITH`,
62
+ * `UNION`, subconsulta nem `*`) e o número de itens bate com o de colunas; nos demais casos, `null`, e vale o SQL
63
+ * inteiro, porque um apelido de subconsulta ou CTE esconderia a coluna pessoal.
64
+ */
65
+ function itensDoSelect(sql, dialeto, colunas) {
66
+ let tokens;
67
+ try {
68
+ tokens = tokenizar(sql, dialeto).filter((t) => t.tipo !== 'comentario');
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ const palavras = tokens.filter((t) => t.tipo === 'palavra').map((t) => t.valor);
74
+ if (tokens[0]?.tipo !== 'palavra' || tokens[0].valor !== 'select')
75
+ return null;
76
+ if (palavras.filter((p) => p === 'select').length !== 1)
77
+ return null;
78
+ if (palavras.some((p) => p === 'with' || p === 'union' || p === 'intersect' || p === 'except'))
79
+ return null;
80
+ const itens = [[]];
81
+ let profundidade = 0;
82
+ let anterior = 'select';
83
+ for (const t of tokens.slice(1)) {
84
+ if (t.tipo === 'palavra' && profundidade === 0 && FIM_DOS_ITENS.has(t.valor))
85
+ break;
86
+ if (t.tipo === 'simbolo') {
87
+ if (t.valor === '(')
88
+ profundidade += 1;
89
+ else if (t.valor === ')')
90
+ profundidade -= 1;
91
+ else if (t.valor === '*' && ['select', 'distinct', 'all', ',', '.'].includes(anterior))
92
+ return null;
93
+ if (t.valor === ',' && profundidade === 0) {
94
+ itens.push([]);
95
+ anterior = t.valor;
96
+ continue;
97
+ }
98
+ }
99
+ itens.at(-1).push(t);
100
+ anterior = t.valor;
101
+ }
102
+ if (itens.length !== colunas)
103
+ return null;
104
+ return itens.map((item) => ({
105
+ ids: item.filter((t) => t.tipo === 'palavra' || t.tipo === 'identificador-citado').map((t) => t.valor),
106
+ contagem: ehContagem(item),
107
+ }));
108
+ }
109
+ function colunaPessoalCitada(ids, mascarar) {
25
110
  const extras = new Set(mascarar.map((r) => r.coluna));
26
- for (const id of identificadores(opcoes.sql, opcoes.dialeto)) {
111
+ for (const id of ids) {
27
112
  const categoria = classificarColuna(id);
28
113
  if (categoria)
29
- return categoria;
114
+ return { categoria, identificador: id };
30
115
  if (extras.has(id))
31
- return 'mascarar_colunas';
116
+ return { categoria: 'mascarar_colunas', identificador: id };
32
117
  }
33
118
  return null;
34
119
  }
35
120
  function ehBinario(valor) {
36
121
  return valor instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(valor));
37
122
  }
123
+ function ehFlag(valor) {
124
+ if (typeof valor === 'boolean')
125
+ return true;
126
+ if (typeof valor === 'string')
127
+ return valor.trim().length <= 1;
128
+ if (typeof valor === 'number')
129
+ return Number.isInteger(valor) && valor >= 0 && valor <= 9;
130
+ if (typeof valor === 'bigint')
131
+ return valor >= 0n && valor <= 9n;
132
+ return false;
133
+ }
134
+ /** Valor que, numa coluna classificada pelo nome, segue só para a detecção pelo conteúdo. */
135
+ function dispensaMascaraDoNome(valor, categoria) {
136
+ if (!CATEGORIAS_DE_FORMATO.has(categoria))
137
+ return false;
138
+ return ehFlag(valor) || (categoria === 'email' && (typeof valor === 'number' || typeof valor === 'bigint'));
139
+ }
38
140
  function mascararStringLivre(texto, contador) {
39
141
  const inicio = texto.trimStart()[0];
40
142
  if (inicio === '{' || inicio === '[') {
@@ -56,22 +158,23 @@ function mascararStringLivre(texto, contador) {
56
158
  for (const t of trechos) {
57
159
  saida += texto.slice(posicao, t.inicio) + String(mascararValor(texto.slice(t.inicio, t.fim), t.categoria));
58
160
  posicao = t.fim;
161
+ contar(contador, t.categoria);
59
162
  }
60
- contador.valores += trechos.length;
61
163
  return saida + texto.slice(posicao);
62
164
  }
63
165
  function mascararPorValor(valor, testarNumero, contador) {
64
166
  if (typeof valor === 'string')
65
167
  return mascararStringLivre(valor, contador);
66
168
  if (typeof valor === 'number' || typeof valor === 'bigint') {
67
- if (testarNumero && classificarNumero(valor) !== null) {
68
- contador.valores += 1;
169
+ const categoria = testarNumero ? classificarNumero(valor) : null;
170
+ if (categoria !== null) {
171
+ contar(contador, categoria);
69
172
  return OCULTO;
70
173
  }
71
174
  return valor;
72
175
  }
73
176
  if (ehBinario(valor)) {
74
- contador.valores += 1;
177
+ contar(contador, 'binario');
75
178
  return OCULTO;
76
179
  }
77
180
  if (valor instanceof Date)
@@ -89,18 +192,32 @@ function mascararEstrutura(valor, contador) {
89
192
  const saida = {};
90
193
  for (const [chave, item] of Object.entries(valor)) {
91
194
  const categoria = classificarColuna(chave);
92
- if (categoria === null) {
195
+ if (categoria === null || dispensaMascaraDoNome(item, categoria)) {
93
196
  saida[chave] = mascararPorValor(item, true, contador);
94
197
  }
95
198
  else {
96
199
  const mascarado = mascararValor(item, categoria);
97
200
  if (mascarado !== item)
98
- contador.valores += 1;
201
+ contar(contador, categoria);
99
202
  saida[chave] = mascarado;
100
203
  }
101
204
  }
102
205
  return saida;
103
206
  }
207
+ /**
208
+ * Texto calculado que é um número (o PostgreSQL devolve `count`, `sum` e `numeric` como texto) é testado como
209
+ * número, como na D-04: o inteiro também passa pelo CPF de 9 e 10 dígitos que o `CAST(cpf AS bigint)` produz.
210
+ */
211
+ function mascararNumeroEmTexto(texto, contador) {
212
+ if (INTEIRO_EM_TEXTO.test(texto) && texto.length <= 19) {
213
+ const categoria = classificarNumero(BigInt(texto));
214
+ if (categoria !== null) {
215
+ contar(contador, categoria);
216
+ return OCULTO;
217
+ }
218
+ }
219
+ return mascararStringLivre(texto, contador);
220
+ }
104
221
  function tratamentoDaColuna(nome, origem, mascarar, liberar) {
105
222
  if (casa(mascarar, nome, origem))
106
223
  return { tipo: 'ocultar', categoria: 'mascarar_colunas' };
@@ -114,36 +231,57 @@ function tratamentoDaColuna(nome, origem, mascarar, liberar) {
114
231
  }
115
232
  /**
116
233
  * Mascara os dados pessoais de um resultado pela origem da coluna, pelo nome, pelas regras do projeto e pelo
117
- * valor. Não altera o resultado recebido; com `ativo: false`, devolve-o sem mudança.
234
+ * valor, célula a célula. Não altera o resultado recebido; com `ativo: false`, devolve-o sem mudança.
118
235
  */
119
236
  export function mascararResultado(resultado, opcoes) {
120
237
  if (!opcoes.ativo)
121
- return { resultado, colunasMascaradas: [], valoresEmTexto: 0 };
238
+ return { resultado, colunasMascaradas: [], valoresEmTexto: 0, detectados: [] };
122
239
  const mascarar = lerRegras(opcoes.regras.mascarar_colunas);
123
240
  const liberar = lerRegras(opcoes.regras.liberar_colunas);
124
241
  const tratamentos = resultado.colunas.map((nome, i) => tratamentoDaColuna(nome, resultado.origens === undefined ? undefined : resultado.origens[i], mascarar, liberar));
125
- let citada;
126
- const citacao = () => {
127
- if (citada === undefined)
128
- citada = colunaPessoalCitada(opcoes, mascarar);
129
- return citada;
242
+ const citacoes = new Map();
243
+ let itens;
244
+ const item = (i) => {
245
+ if (itens === undefined)
246
+ itens = itensDoSelect(opcoes.sql, opcoes.dialeto, resultado.colunas.length);
247
+ return itens ? itens[i] : null;
248
+ };
249
+ const citacao = (i) => {
250
+ if (!citacoes.has(i)) {
251
+ citacoes.set(i, colunaPessoalCitada(item(i)?.ids ?? identificadores(opcoes.sql, opcoes.dialeto), mascarar));
252
+ }
253
+ return citacoes.get(i);
130
254
  };
131
255
  const marcadas = new Map();
132
- const contador = { valores: 0 };
256
+ const contadores = resultado.colunas.map(() => novoContador());
133
257
  const linhas = resultado.linhas.map((linha) => linha.map((valor, i) => {
134
258
  const t = tratamentos[i] ?? { tipo: 'valor', calculada: false, numero: true };
259
+ const contador = contadores[i] ?? novoContador();
135
260
  if (t.tipo === 'ocultar') {
136
- marcadas.set(i, t.categoria);
261
+ marcadas.set(i, { coluna: resultado.colunas[i] ?? String(i), categoria: t.categoria, motivo: 'projeto' });
137
262
  return valor === null || valor === undefined ? valor : OCULTO;
138
263
  }
139
264
  if (t.tipo === 'categoria') {
140
- marcadas.set(i, t.categoria);
141
- return mascararValor(valor, t.categoria);
265
+ if (dispensaMascaraDoNome(valor, t.categoria))
266
+ return mascararPorValor(valor, true, contador);
267
+ const mascarado = mascararValor(valor, t.categoria);
268
+ if (mascarado !== valor) {
269
+ marcadas.set(i, { coluna: resultado.colunas[i] ?? String(i), categoria: t.categoria, motivo: 'nome' });
270
+ }
271
+ return mascarado;
142
272
  }
143
273
  if (t.calculada && typeof valor === 'string') {
144
- const categoria = citacao();
145
- if (categoria !== null) {
146
- marcadas.set(i, categoria);
274
+ const citada = citacao(i);
275
+ // Um número só escapa da citação quando é contagem: `sum(salario)` por pessoa ou `substr(cpf, 1, 9)` não.
276
+ if (NUMERO_EM_TEXTO.test(valor) && (citada === null || item(i)?.contagem))
277
+ return mascararNumeroEmTexto(valor, contador);
278
+ if (citada !== null) {
279
+ marcadas.set(i, {
280
+ coluna: resultado.colunas[i] ?? String(i),
281
+ categoria: citada.categoria,
282
+ motivo: 'sql',
283
+ citada: citada.identificador,
284
+ });
147
285
  return OCULTO;
148
286
  }
149
287
  }
@@ -152,10 +290,10 @@ export function mascararResultado(resultado, opcoes) {
152
290
  const copia = { ...resultado, colunas: [...resultado.colunas], linhas };
153
291
  if (resultado.origens !== undefined)
154
292
  copia.origens = resultado.origens.map((o) => (o ? { ...o } : o));
155
- const colunasMascaradas = [...marcadas.keys()]
156
- .sort((a, b) => a - b)
157
- .map((i) => ({ coluna: resultado.colunas[i] ?? String(i), categoria: marcadas.get(i) }));
158
- return { resultado: copia, colunasMascaradas, valoresEmTexto: contador.valores };
293
+ const colunasMascaradas = [...marcadas.keys()].sort((a, b) => a - b).map((i) => marcadas.get(i));
294
+ const detectados = contadores.flatMap((c, i) => c.valores === 0 ? [] : [{ coluna: resultado.colunas[i] ?? String(i), categorias: [...c.categorias] }]);
295
+ const valoresEmTexto = contadores.reduce((total, c) => total + c.valores, 0);
296
+ return { resultado: copia, colunasMascaradas, valoresEmTexto, detectados };
159
297
  }
160
298
  /** Categoria com que a coluna `tabela.coluna` sai mascarada pelo nome ou pelas regras do projeto, ou `null`. */
161
299
  export function categoriaDaColuna(tabela, coluna, regras) {
@@ -164,5 +302,5 @@ export function categoriaDaColuna(tabela, coluna, regras) {
164
302
  }
165
303
  /** Aplica a detecção por valor a um texto solto, como a mensagem de erro do banco. */
166
304
  export function mascararTexto(texto) {
167
- return mascararStringLivre(texto, { valores: 0 });
305
+ return mascararStringLivre(texto, novoContador());
168
306
  }
@@ -8,7 +8,7 @@ const PARTES_ISOLADAS = [
8
8
  ['cep', ['cep', 'zip', 'postal']],
9
9
  ['nascimento', ['nascimento', 'nasc', 'birth', 'birthday']],
10
10
  ['nome', ['sobrenome']],
11
- ['financeiro', ['salario', 'renda', 'salary', 'income', 'agencia', 'iban', 'pix']],
11
+ ['financeiro', ['salario', 'renda', 'salary', 'income', 'iban', 'pix']],
12
12
  ['cartao', ['cartao', 'card', 'cvv', 'cvc']],
13
13
  ['ip', ['ip']],
14
14
  ['segredo', ['senha', 'password', 'passwd', 'pwd', 'hash', 'token', 'secret', 'apikey']],
@@ -35,6 +35,10 @@ const COMBINACOES = [
35
35
  ['nome', [['first', 'last', 'full'], ['name']]],
36
36
  ['nome', [['nome', 'name'], PARTES_DE_PESSOA]],
37
37
  ];
38
+ /** Com uma destas partes, `pis` é o tributo (`valor_pis`, `retencao_pis_cofins`), não o número PIS do trabalhador. */
39
+ const PARTES_DE_TRIBUTO = new Set([
40
+ 'valor', 'vl', 'vlr', 'percentual', 'perc', 'aliquota', 'aliq', 'bc', 'base', 'retencao', 'ret', 'cst', 'cofins',
41
+ ]);
38
42
  const TERMOS_FORTES = [
39
43
  ['cpf', 'cpf'],
40
44
  ['cnpj', 'cnpj'],
@@ -67,6 +71,8 @@ export function partesDoNome(nome) {
67
71
  export function classificarColuna(nome) {
68
72
  const partes = partesDoNome(nome);
69
73
  const tem = new Set(partes);
74
+ if (partes.some((p) => PARTES_DE_TRIBUTO.has(p)))
75
+ tem.delete('pis');
70
76
  for (const [categoria, listas] of COMBINACOES) {
71
77
  if (listas.every((lista) => lista.some((p) => tem.has(p))))
72
78
  return categoria;
@@ -90,8 +96,9 @@ function soDigitos(texto) {
90
96
  }
91
97
  function digitoVerificador(digitos, pesos) {
92
98
  let soma = 0;
99
+ // No CNPJ alfanumérico, cada caractere vale o código ASCII menos 48; nos dígitos, isso é o próprio dígito.
93
100
  for (let i = 0; i < pesos.length; i++)
94
- soma += Number(digitos[i]) * (pesos[i] ?? 0);
101
+ soma += ((digitos.charCodeAt(i) || 48) - 48) * (pesos[i] ?? 0);
95
102
  const resto = soma % 11;
96
103
  return resto < 2 ? 0 : 11 - resto;
97
104
  }
@@ -102,9 +109,10 @@ export function cpfValido(cpf) {
102
109
  return (digitoVerificador(d, [10, 9, 8, 7, 6, 5, 4, 3, 2]) === Number(d[9]) &&
103
110
  digitoVerificador(d, [11, 10, 9, 8, 7, 6, 5, 4, 3, 2]) === Number(d[10]));
104
111
  }
112
+ /** Aceita também o CNPJ alfanumérico: 12 caracteres `[0-9A-Z]` e 2 dígitos verificadores. */
105
113
  export function cnpjValido(cnpj) {
106
- const d = soDigitos(cnpj);
107
- if (d.length !== 14 || DIGITOS_REPETIDOS.test(d))
114
+ const d = cnpj.toUpperCase().replace(/[^0-9A-Z]/g, '');
115
+ if (!/^[0-9A-Z]{12}\d{2}$/.test(d) || DIGITOS_REPETIDOS.test(d))
108
116
  return false;
109
117
  return (digitoVerificador(d, [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]) === Number(d[12]) &&
110
118
  digitoVerificador(d, [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]) === Number(d[13]));
@@ -131,6 +139,7 @@ const MAX_DIGITOS = 19;
131
139
  const CPF_FORMATADO = /^\d{3}\.\d{3}\.\d{3}-\d{2}$/;
132
140
  const CNPJ_FORMATADO = /^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/;
133
141
  const EMAIL = /[\p{L}\p{M}\p{N}._%+-]+@[\p{L}\p{M}\p{N}-]+(?:\.[\p{L}\p{M}\p{N}-]+)*\.[\p{L}\p{M}]{2,}/gu;
142
+ const CNPJ_ALFANUMERICO = /(?<![\p{L}\p{N}])[0-9A-Z]{2}\.?[0-9A-Z]{3}\.?[0-9A-Z]{3}\/?[0-9A-Z]{4}-?\d{2}(?![\p{L}\p{N}])/gu;
134
143
  const TELEFONE = /(?<![\d+])(?:\+55\s?)?(?:\(\d{2}\)\s?|\d{2}[\s-])9?\d{4}[\s-]?\d{4}(?!\d)/g;
135
144
  /** `seq` é um trecho de grupos inteiros com no máximo `MAX_DIGITOS` dígitos. */
136
145
  function classificarSequencia(seq) {
@@ -185,6 +194,10 @@ export function encontrarNoTexto(texto) {
185
194
  ...trechosDe(texto, EMAIL, 'email'),
186
195
  ...trechosDe(texto, TELEFONE, 'telefone'),
187
196
  ...trechosDeDigitos(texto),
197
+ ...trechosDe(texto, CNPJ_ALFANUMERICO, 'cnpj').filter((t) => {
198
+ const trecho = texto.slice(t.inicio, t.fim);
199
+ return /[A-Z]/.test(trecho) && cnpjValido(trecho);
200
+ }),
188
201
  ].sort((a, b) => prioridade(a.categoria) - prioridade(b.categoria) || b.fim - b.inicio - (a.fim - a.inicio) || a.inicio - b.inicio);
189
202
  const escolhidos = [];
190
203
  for (const t of candidatos) {
@@ -1,8 +1,8 @@
1
1
  export const OCULTO = '[OCULTO]';
2
2
  const CONECTIVOS = new Set(['da', 'de', 'do', 'das', 'dos', 'e']);
3
- function manterIntervalo(texto, inicio, fim) {
3
+ function manterIntervalo(texto, inicio, fim, caractere = /\d/g) {
4
4
  let posicao = 0;
5
- return texto.replace(/\d/g, (digito) => {
5
+ return texto.replace(caractere, (digito) => {
6
6
  posicao += 1;
7
7
  return posicao >= inicio && posicao <= fim ? digito : '*';
8
8
  });
@@ -13,9 +13,9 @@ function mascararCpf(texto) {
13
13
  return manterIntervalo(texto, 4, 9);
14
14
  }
15
15
  function mascararCnpj(texto) {
16
- if (!/^(\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}|\d{14})$/.test(texto))
16
+ if (!/^([0-9A-Z]{2}\.[0-9A-Z]{3}\.[0-9A-Z]{3}\/[0-9A-Z]{4}-\d{2}|[0-9A-Z]{12}\d{2})$/.test(texto))
17
17
  return OCULTO;
18
- return manterIntervalo(texto, 3, 12);
18
+ return manterIntervalo(texto, 3, 12, /[0-9A-Z]/g);
19
19
  }
20
20
  function mascararDocumento(texto) {
21
21
  if (!/^[A-Za-z0-9./\s-]+$/.test(texto))
@@ -301,6 +301,30 @@ export function gravarConexaoProjeto(root, conexao, { substituir = false } = {})
301
301
  const base = raw ?? lerJsonParaEscrita(file, 'banco_dados.conexoes');
302
302
  writeFileSync(file, `${JSON.stringify(comSecaoBanco(base, { ...banco, conexoes: proximas }), null, 2)}\n`);
303
303
  }
304
+ /**
305
+ * Tira de `banco_dados.conexoes` a primeira entrada com o nome (objeto, mesmo inválido, ou texto), preservando
306
+ * o resto do JSON, e devolve o que leu. Sem config ou sem o nome, devolve null e não escreve.
307
+ * JSON inválido ou `conexoes` que não é lista lança sem tocar no arquivo.
308
+ */
309
+ export function removerConexaoProjeto(root, nome) {
310
+ const file = join(root, CONFIG_PATH);
311
+ if (!existsSync(file))
312
+ return null;
313
+ const raw = lerJsonParaEscrita(file, 'banco_dados.conexoes');
314
+ const banco = secaoBanco(raw);
315
+ const conexoes = banco.conexoes === undefined ? [] : banco.conexoes;
316
+ if (!Array.isArray(conexoes))
317
+ throw new Error(`banco_dados.conexoes em ${CONFIG_PATH} não é uma lista; corrija-o antes de remover a conexão ${nome}.`);
318
+ const posicao = conexoes.findIndex((entrada) => (isObject(entrada) ? entrada.nome : entrada) === nome);
319
+ if (posicao === -1)
320
+ return null;
321
+ const entrada = conexoes[posicao];
322
+ const proximas = conexoes.filter((_atual, i) => i !== posicao);
323
+ writeFileSync(file, `${JSON.stringify(comSecaoBanco(raw, { ...banco, conexoes: proximas }), null, 2)}\n`);
324
+ if (!isObject(entrada))
325
+ return nome;
326
+ return typeof entrada.dsn_env === 'string' ? { nome, dsn_env: entrada.dsn_env } : { nome };
327
+ }
304
328
  /**
305
329
  * Grava só `banco_dados.registrar_mcp`, preservando o resto do JSON; sem config, cria uma com os padrões
306
330
  * e essa chave. JSON inválido é recusado sem tocar no arquivo.
@@ -425,7 +425,7 @@ function checkBancoProjeto(root, config, env) {
425
425
  id: 'banco-variavel-ausente',
426
426
  severidade: 'aviso',
427
427
  mensagem: `A conexão ${nome}, declarada em banco_dados.conexoes de ${join(root, CONFIG_PATH)}, lê o DSN da variável ${dsn_env}, que não está definida no ambiente, em ${SETTINGS_LOCAL_PATH} nem no .env.`,
428
- correcao: `Defina ${dsn_env} com \`npx @fontdata/ai-harness banco-dados adicionar ${nome} --projeto\` ou no ambiente do terminal.`,
428
+ correcao: `Defina ${dsn_env} com \`npx @fontdata/ai-harness banco-dados adicionar ${nome}\` ou no ambiente do terminal.`,
429
429
  });
430
430
  }
431
431
  const envLocal = lerEnvSettingsLocal(root);
@@ -487,7 +487,7 @@ export function checkBanco(root, config, env = process.env) {
487
487
  id: 'banco-conexao-ausente',
488
488
  severidade: 'aviso',
489
489
  mensagem: `A conexão ${nome}, citada em banco_dados.conexoes de ${join(root, CONFIG_PATH)}, não está no cadastro desta máquina (${caminho}).`,
490
- correcao: `Cadastre-a com \`npx @fontdata/ai-harness banco-dados adicionar ${nome}\`.`,
490
+ correcao: `Cadastre-a com \`npx @fontdata/ai-harness banco-dados adicionar ${nome} --global\`.`,
491
491
  });
492
492
  }
493
493
  for (const nome of doCadastro.filter((nome) => conexoes[nome]?.mascaramento === false)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fontdata/ai-harness",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "Harness de engenharia com IA da Fontdata para Claude Code: roda os slash commands direto na pasta do projeto via npx, sem instalar nada — ou instala-os em .claude/commands.",
5
5
  "type": "module",
6
6
  "bin": {