@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.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +7 -6
- package/dist/banco-dados/ambiente.js +17 -3
- package/dist/banco-dados/command.js +58 -20
- package/dist/banco-dados/conexao.js +1 -1
- package/dist/banco-dados/ferramentas.js +15 -7
- package/dist/banco-dados/lgpd/aplicar.js +168 -30
- package/dist/banco-dados/lgpd/detectar.js +17 -4
- package/dist/banco-dados/lgpd/mascarar.js +4 -4
- package/dist/config/config.js +24 -0
- package/dist/doctor/checks.js +2 -2
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fontdata",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"mcpServers": {
|
|
5
|
-
"banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@3.
|
|
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
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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(
|
|
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
|
-
'
|
|
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
|
|
22
|
-
'
|
|
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', '--
|
|
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> [--
|
|
125
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
return { resultado: copia, colunasMascaradas, valoresEmTexto
|
|
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,
|
|
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', '
|
|
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 +=
|
|
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 =
|
|
107
|
-
if (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(
|
|
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 (!/^(
|
|
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))
|
package/dist/config/config.js
CHANGED
|
@@ -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.
|
package/dist/doctor/checks.js
CHANGED
|
@@ -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}
|
|
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.
|
|
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": {
|