@fontdata/ai-harness 3.1.0 → 3.1.2

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.1.0",
3
+ "version": "3.1.2",
4
4
  "mcpServers": {
5
- "banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@3.1.0", "banco-dados-mcp"] }
5
+ "banco_dados": { "command": "npx", "args": ["-y", "@fontdata/ai-harness@3.1.2", "banco-dados-mcp"] }
6
6
  },
7
7
  "description": "Harness de engenharia com IA da Fontdata para Claude Code",
8
8
  "author": { "name": "Fontdata" },
@@ -83,14 +83,23 @@ function paraJson(dados) {
83
83
  function plural(n, singular, plural) {
84
84
  return `${n} ${n === 1 ? singular : plural}`;
85
85
  }
86
- 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 }) {
87
93
  const partes = [];
88
94
  if (colunasMascaradas.length > 0) {
89
- 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(', ');
90
98
  partes.push(`${plural(colunasMascaradas.length, 'coluna', 'colunas')} (${lista})`);
91
99
  }
92
100
  if (valoresEmTexto > 0) {
93
- 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})`);
94
103
  }
95
104
  return partes.length === 0 ? '' : `\n\nMascaramento LGPD: ${partes.join(' e ')}.`;
96
105
  }
@@ -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))
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { allCommands, loadCatalog } from './catalog.js';
6
+ import { acharRaiz } from './raiz.js';
6
7
  import { runCommand, runnableIds, suggestIds } from './run.js';
7
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
9
  /** Comandos renomeados depois da 2.7.0: o nome antigo aponta o novo em vez de cair em "Comando desconhecido". */
@@ -127,7 +128,7 @@ async function main() {
127
128
  }
128
129
  if (cmd === 'doctor') {
129
130
  const { formatFindings, runDoctor } = await import('./doctor/checks.js');
130
- const root = resolve(process.cwd(), argv[1] ?? '.');
131
+ const root = argv[1] === undefined ? acharRaiz() : resolve(process.cwd(), argv[1]);
131
132
  if (!existsSync(root))
132
133
  throw new Error(`Pasta não encontrada: ${root}`);
133
134
  const findings = runDoctor(root);
@@ -6,6 +6,7 @@ import { suggestFlag } from '../install/flags.js';
6
6
  import { hashContent } from '../install/lock.js';
7
7
  import { executePlan, packageInfo } from '../install/plan.js';
8
8
  import { mergeSettings } from '../install/settings.js';
9
+ import { acharRaiz } from '../raiz.js';
9
10
  import { COMMENTS_BASELINE_PATH, COMMENTS_HOOK_PATH, COMMENTS_RULE_PATH, commentsHooksConfig, ensureCommentsBaseline } from './instalar.js';
10
11
  export { COMMENTS_HOOK_PATH } from './instalar.js';
11
12
  const FLAGS = ['--yes', '-y', '--sim'];
@@ -27,7 +28,7 @@ export function runCommentsCommand(argv) {
27
28
  throw new Error(`Flag desconhecida: ${token}.${suggestion ? ` Você quis dizer ${suggestion}?` : ''}`);
28
29
  }
29
30
  }
30
- const root = resolve(process.cwd(), path ?? '.');
31
+ const root = path === undefined ? acharRaiz() : resolve(process.cwd(), path);
31
32
  if (!existsSync(root))
32
33
  throw new Error(`Pasta não encontrada: ${root}`);
33
34
  const rule = readFileSync(join(templatesDir, 'rules', 'comentarios.md'));
@@ -3,6 +3,7 @@ import { EOL } from 'node:os';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { ARTIFACT_TYPES, readArtifact, WORKS_DIR } from '../contratos/artefatos.js';
5
5
  import { suggestFlag } from '../install/flags.js';
6
+ import { acharRaiz } from '../raiz.js';
6
7
  export const INDEX_PATH = join('.ai-harness', 'memoria', 'indice.json');
7
8
  export const INDEX_VERSION = 1;
8
9
  const BACKSLASH = String.fromCharCode(92);
@@ -159,7 +160,7 @@ export function runMemoryCommand(argv) {
159
160
  else
160
161
  bugId = value;
161
162
  }
162
- const root = resolve(process.cwd(), path ?? '.');
163
+ const root = path === undefined ? acharRaiz() : resolve(process.cwd(), path);
163
164
  if (!existsSync(root))
164
165
  throw new Error(`Pasta não encontrada: ${root}`);
165
166
  const index = buildIndex(root);
package/dist/raiz.js ADDED
@@ -0,0 +1,21 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ /** Sobe até o diretório mais próximo que tenha o marcador, ou devolve undefined. */
4
+ function subirAte(inicio, marcador) {
5
+ for (let pasta = inicio;; pasta = dirname(pasta)) {
6
+ if (existsSync(join(pasta, marcador)))
7
+ return pasta;
8
+ if (dirname(pasta) === pasta)
9
+ return undefined;
10
+ }
11
+ }
12
+ /**
13
+ * Raiz do projeto para os comandos rodados sem caminho: o Bash dos agentes herda o `cd` de comandos anteriores, então
14
+ * o diretório atual pode ser uma subpasta. Vale o `.ai-harness/` mais próximo, depois o `.git` (arquivo em submódulo e
15
+ * worktree); sem nenhum, o próprio diretório. `CLAUDE_PROJECT_DIR` fica de fora: não é garantida no Bash, e a suíte
16
+ * roda dentro do Claude Code.
17
+ */
18
+ export function acharRaiz(inicio = process.cwd()) {
19
+ const pasta = resolve(inicio);
20
+ return subirAte(pasta, '.ai-harness') ?? subirAte(pasta, '.git') ?? pasta;
21
+ }
@@ -2,6 +2,7 @@ import { join } from 'node:path';
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import { templatesDir } from '../catalog.js';
4
4
  import { suggestFlag } from '../install/flags.js';
5
+ import { acharRaiz } from '../raiz.js';
5
6
  /** O CLI grava pela mesma biblioteca dos hooks, para as duas formas produzirem o mesmo formato. */
6
7
  export async function loadRastroLib() {
7
8
  return (await import(pathToFileURL(join(templatesDir, 'hooks', 'lib', 'rastro.mjs')).href));
@@ -41,6 +42,6 @@ export async function runEventCommand(argv) {
41
42
  }
42
43
  }
43
44
  }
44
- (await loadRastroLib()).recordEvent(process.cwd(), fields);
45
+ (await loadRastroLib()).recordEvent(acharRaiz(), fields);
45
46
  return 0;
46
47
  }
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { setTimeout as sleep } from 'node:timers/promises';
4
4
  import { readArtifact } from '../contratos/artefatos.js';
5
5
  import { suggestFlag } from '../install/flags.js';
6
+ import { acharRaiz } from '../raiz.js';
6
7
  import { loadRastroLib } from '../rastro/command.js';
7
8
  const FINISHED = new Set(['concluido', 'cancelado']);
8
9
  const normalize = (path) => path.trim().replace(/^\.\//, '').replace(/\/+$/, '');
@@ -84,7 +85,7 @@ export async function waitForFreeFiles(root, workId, options) {
84
85
  }
85
86
  const FLAGS = ['--aguardar', '--intervalo'];
86
87
  /** `ai-harness conflitos <id> [--aguardar] [--intervalo <segundos>]` — sai com 1 quando há conflito sem --aguardar. */
87
- export async function runConflictsCommand(argv, root = process.cwd()) {
88
+ export async function runConflictsCommand(argv, root = acharRaiz()) {
88
89
  const [workId, ...rest] = argv;
89
90
  if (!workId || workId.startsWith('-')) {
90
91
  throw new Error('Informe o id do trabalho. Ex.: npx @fontdata/ai-harness conflitos 2026-09-18-login --aguardar');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fontdata/ai-harness",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
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": {