synthesisui 0.16.379 → 0.16.381

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.
@@ -621,7 +621,10 @@ async function runFix(root, d, writing) {
621
621
  await appendEvent(root, {
622
622
  kind: "fix",
623
623
  at: new Date().toISOString(),
624
- named: result.applied.length,
624
+ /** ESCRITAS, e não achados - ver `Applied.occurrences`: um achado de folha minificada vale
625
+ * por sete trocas, e o ledger que contasse achados registraria um ato menor do que o que
626
+ * aconteceu no repositório dele. */
627
+ named: result.applied.reduce((n, a) => n + a.occurrences, 0),
625
628
  });
626
629
  console.log("");
627
630
  console.log(body(paint.dim("Read the diff before committing - it is your code, and this replaced literals with the tokens your own system declares.")));
@@ -18,8 +18,10 @@
18
18
  * confiança:
19
19
  *
20
20
  * 1. tocar uma linha que mudou desde a medição - se o texto não bate, pula e conta
21
- * 2. trocar mais de uma ocorrência por achado - dois `#fff` na mesma linha são dois achados,
22
- * e cada um tem a sua própria linha no relatório
21
+ * 2. sair da linha que o achado nomeia - a troca acontece naquela linha e em nenhuma outra.
22
+ * DENTRO dela, todas as ocorrências daquele valor são trocadas, e o recibo conta ESCRITAS,
23
+ * não achados: o scan emite um achado por (arquivo, linha, valor), e numa folha minificada
24
+ * esse um vale por sete
23
25
  * 3. escrever em arquivo que o scanner marcou como fora (gerado, vendored) - esses nunca
24
26
  * chegam aqui, porque o walk já os pula
25
27
  */
@@ -27,17 +29,117 @@ import { readFile, writeFile } from "node:fs/promises";
27
29
  import { join } from "node:path";
28
30
  import { nameToWrite } from "./scan.js";
29
31
  /**
30
- * A substituição numa linha só, e ela é deliberadamente burra.
32
+ * ONDE O VALOR DE UMA DECLARAÇÃO DESTE MESMO TOKEN COMEÇA E TERMINA, nesta linha.
31
33
  *
32
- * `indexOf` do literal exato, primeira ocorrência, nada mais. Um literal de cor ou de comprimento não
33
- * tem sintaxe ambígua o suficiente para justificar um parser aqui, e um parser que erra num arquivo de
34
- * outra pessoa custa mais que os achados que ele conserta.
34
+ * Só os intervalos do token que a troca ESCREVERIA: é dentro deles, e só ali, que trocar produz
35
+ * `--x: var(--x)`. Todo o resto da linha - inclusive a declaração de OUTRO token dele - é uso
36
+ * legítimo, e continua trocável.
35
37
  */
36
- function swap(line, literal, token) {
37
- const at = line.indexOf(literal);
38
- if (at === -1)
39
- return null;
40
- return `${line.slice(0, at)}var(${token})${line.slice(at + literal.length)}`;
38
+ function selfRanges(line, name) {
39
+ const out = [];
40
+ for (const m of line.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;}]*)/gi)) {
41
+ if (m[1] !== name)
42
+ continue;
43
+ const at = (m.index ?? 0) + m[0].length - m[2].length;
44
+ out.push([at, at + m[2].length]);
45
+ }
46
+ return out;
47
+ }
48
+ /**
49
+ * A TROCA NA LINHA, EM TODAS AS VEZES QUE O VALOR APARECE NELA - e era aqui que o número mentia.
50
+ *
51
+ * O QUE ACONTECIA: o scan emite UM achado por (arquivo, linha, valor), e esta função trocava a
52
+ * PRIMEIRA ocorrência. Numa folha minificada a linha inteira é o arquivo: em
53
+ * `apps/landing/app/styles/pulse.css` a linha 7 escreve `#6b6b78` sete vezes, o comando trocava uma,
54
+ * contava uma, e deixava seis - sem dizer. A medição seguinte reencontrava o mesmo achado, e o número
55
+ * que o comando prometeu passava a não bater com o número que a próxima leitura mostra.
56
+ *
57
+ * MEDIDO EM DUAS POPULAÇÕES (05/09, pelo caminho real do `doctor --fix --write`, antes e depois):
58
+ *
59
+ * codelevel 114 achados aplicados · escrevia 114 · agora escreve **126** · discrepância 5 -> 0
60
+ * frontend-hub 1010 achados aplicados · escrevia 1010 · agora escreve **1012** · já era 0
61
+ *
62
+ * E A DIFERENÇA QUE ISTO FECHA: no codelevel o comando anunciava 114 trocas e a medição seguinte
63
+ * mostrava só 109 achados a menos - **5 sem explicação**, que são estes: cinco linhas onde o valor
64
+ * aparecia mais de uma vez, o achado continuava lá depois do conserto, e o cliente que rodasse os
65
+ * dois comandos em sequência via os dois números. Agora a queda é 114 de 114.
66
+ *
67
+ * O TETO É MAIOR QUE O EFEITO DE HOJE, e vale dizer: no codelevel, 39 dos 208 achados NOMEADOS
68
+ * escondem 235 ocorrências, 234 delas num único arquivo minificado - a maior parte em achados que o
69
+ * `--fix` ainda não aplica porque apontam para tokens nossos, que só resolvem depois da fiação. No
70
+ * dia em que ele fizer a fiação, são essas que passam a ser escritas.
71
+ *
72
+ * Não é forma de um repositório: é toda folha que alguém minifica, e todo arquivo que escreve o mesmo
73
+ * valor duas vezes na mesma linha.
74
+ *
75
+ * O GUARD DA AUTO-REFERÊNCIA PASSA A VALER POR OCORRÊNCIA, e não pela linha inteira. Numa linha que
76
+ * DECLARA o token e também o USA, recusar as duas coisas deixava o uso sem conserto por causa da
77
+ * vizinha; agora a declaração fica intacta e o uso é trocado. Quando toda ocorrência da linha é a
78
+ * própria declaração, o resultado é o de sempre: nada trocado, e o motivo dito.
79
+ *
80
+ * E O VALOR TEM QUE TERMINAR ONDE ELE TERMINA - ver `wholeValue`.
81
+ */
82
+ /**
83
+ * A OCORRÊNCIA É O VALOR INTEIRO, E NÃO O COMEÇO DE OUTRO - e sem isto a troca escreve cor inválida.
84
+ *
85
+ * `#4f46e5` é um prefixo de `#4f46e50b`, que é a MESMA cor com alpha. Trocando pelo prefixo, a linha
86
+ * fica `var(--color-brand-blue)0b`: o navegador descarta a declaração inteira e o elemento perde a
87
+ * cor - o mesmo estrago do ciclo, por outro caminho. Vale igual para comprimento: `1rem` é prefixo de
88
+ * `1remx` e sufixo de `11rem`.
89
+ *
90
+ * NÃO É HIPÓTESE: `apps/landing/app/styles/pulse.css` escreve 8 valores em `#rrggbbaa`, e o
91
+ * `--fix` só não os atingia porque trocava a PRIMEIRA ocorrência da linha e ela calhava de ser um hex
92
+ * puro. Sorte da população, não proteção - e trocando todas, a sorte acaba.
93
+ */
94
+ function wholeValue(line, at, literal) {
95
+ const before = at > 0 ? line[at - 1] : "";
96
+ const after = line[at + literal.length] ?? "";
97
+ return !/[0-9a-z#]/i.test(before) && !/[0-9a-z]/i.test(after);
98
+ }
99
+ /** Onde este valor está escrito INTEIRO nesta linha - a mesma pergunta que a troca faz. */
100
+ function wholeValuesIn(line, literal) {
101
+ const out = [];
102
+ for (let at = line.indexOf(literal); at !== -1;) {
103
+ if (wholeValue(line, at, literal))
104
+ out.push(at);
105
+ at = line.indexOf(literal, at + literal.length);
106
+ }
107
+ return out;
108
+ }
109
+ function swapAll(line, literal, name) {
110
+ const guarded = selfRanges(line, name);
111
+ let out = "";
112
+ let from = 0;
113
+ let count = 0;
114
+ let blocked = 0;
115
+ for (;;) {
116
+ const at = line.indexOf(literal, from);
117
+ if (at === -1)
118
+ break;
119
+ const declares = guarded.some(([start, end]) => at >= start && at + literal.length <= end);
120
+ /**
121
+ * A BORDA NÃO CONTA COMO AUTO-REFERÊNCIA - senão o relatório dá o motivo de outro caso.
122
+ *
123
+ * `blocked` é só a declaração do próprio token, porque é ele que decide a frase impressa quando
124
+ * nada foi trocado. Um prefixo de valor maior não é uma recusa: é um valor que não está escrito
125
+ * naquela linha, e cai no motivo que já existe para isso.
126
+ */
127
+ if (!wholeValue(line, at, literal)) {
128
+ out += line.slice(from, at + literal.length);
129
+ from = at + literal.length;
130
+ continue;
131
+ }
132
+ if (declares) {
133
+ blocked++;
134
+ out += line.slice(from, at + literal.length);
135
+ }
136
+ else {
137
+ count++;
138
+ out += `${line.slice(from, at)}var(${name})`;
139
+ }
140
+ from = at + literal.length;
141
+ }
142
+ return { line: out + line.slice(from), count, blocked };
41
143
  }
42
144
  /**
43
145
  * Aplica o que tem token, em memória, e devolve o conteúdo novo por arquivo.
@@ -53,6 +155,15 @@ export function planFix(d, read) {
53
155
  const lines = new Map();
54
156
  /** A mesma chave, congelada antes da primeira troca - ver o skip de `not-written`. */
55
157
  const originals = new Map();
158
+ /**
159
+ * O MESMO VALOR, NA MESMA LINHA, APONTADO DUAS VEZES É UM CONSERTO SÓ - e não uma linha que mudou.
160
+ *
161
+ * A troca cobre TODAS as ocorrências daquela linha de uma vez, então um segundo achado idêntico não
162
+ * encontra mais o literal e cairia no skip de `moved`, que imprime *"linhas mudaram desde o scan -
163
+ * rode de novo"* num repositório onde ninguém mexeu em nada. Nada é perdido de vista: as duas
164
+ * escritas já estão contadas no `occurrences` do primeiro.
165
+ */
166
+ const done = new Set();
56
167
  /**
57
168
  * DE BAIXO PARA CIMA no arquivo não é necessário - a troca não muda a contagem de linhas - mas a
58
169
  * ORDEM por arquivo é, para dois achados na mesma linha não se atropelarem: o segundo procura o seu
@@ -172,10 +283,15 @@ export function planFix(d, read) {
172
283
  * seguem trocáveis: numa linha minificada, um `background:#fbfaf6` ao lado de um `--violet:...`
173
284
  * continua sendo um uso legítimo.
174
285
  */
175
- const declaring = [
176
- ...(current ?? "").matchAll(/(--[a-z0-9-]+)\s*:\s*([^;}]*)/gi),
177
- ].find(([, , value]) => value?.includes(f.literal));
178
- if (declaring && declaring[1] === name) {
286
+ const already = `${f.file}:${f.line}:${f.literal}`;
287
+ if (done.has(already))
288
+ continue;
289
+ const swapped = current === undefined ? null : swapAll(current, f.literal, name);
290
+ /**
291
+ * TODA OCORRÊNCIA DA LINHA ERA A PRÓPRIA DECLARAÇÃO - o caso que o guard sempre recusou, dito
292
+ * com a mesma frase de antes. O que mudou é que uma linha que declara E usa deixou de cair aqui.
293
+ */
294
+ if (swapped !== null && swapped.count === 0 && swapped.blocked > 0) {
179
295
  skipped.push({
180
296
  file: f.file,
181
297
  line: f.line,
@@ -184,8 +300,7 @@ export function planFix(d, read) {
184
300
  });
185
301
  continue;
186
302
  }
187
- const swapped = current === undefined ? null : swap(current, f.literal, name);
188
- if (swapped === null) {
303
+ if (swapped === null || swapped.count === 0) {
189
304
  /**
190
305
  * DOIS MOTIVOS DIFERENTES, E A TELA DIZIA UM SÓ - e o que ela dizia era falso quase sempre.
191
306
  *
@@ -215,19 +330,21 @@ export function planFix(d, read) {
215
330
  file: f.file,
216
331
  line: f.line,
217
332
  literal: f.literal,
218
- because: before === undefined || before.includes(f.literal)
333
+ because: before === undefined || wholeValuesIn(before, f.literal).length > 0
219
334
  ? "moved"
220
335
  : "not-written",
221
336
  });
222
337
  continue;
223
338
  }
224
- body[idx] = swapped;
339
+ body[idx] = swapped.line;
225
340
  applied.push({
226
341
  file: f.file,
227
342
  line: f.line,
228
343
  literal: f.literal,
229
344
  token: name,
345
+ occurrences: swapped.count,
230
346
  });
347
+ done.add(already);
231
348
  }
232
349
  for (const [file, body] of lines) {
233
350
  const touched = applied.some((a) => a.file === file);
@@ -292,10 +409,17 @@ export function describeFix(result, dry) {
292
409
  : "Nothing to apply.");
293
410
  return lines;
294
411
  }
295
- lines.push(`${dry ? "Would replace" : "Replaced"} ${applied.length} hand-written value${applied.length === 1 ? "" : "s"} with the token your system already has, across ${result.files} file${result.files === 1 ? "" : "s"}.`);
412
+ /**
413
+ * O NÚMERO É O DE ESCRITAS, e não o de achados - ver `Applied.occurrences`.
414
+ *
415
+ * Um achado numa linha minificada vale por sete trocas. Contando achados, o recibo dizia 114 onde o
416
+ * comando escreveu 349, e a medição seguinte não batia com nenhum dos dois números.
417
+ */
418
+ const uses = applied.reduce((n, a) => n + a.occurrences, 0);
419
+ lines.push(`${dry ? "Would replace" : "Replaced"} ${uses} hand-written value${uses === 1 ? "" : "s"} with the token your system already has, across ${result.files} file${result.files === 1 ? "" : "s"}.`);
296
420
  const byToken = new Map();
297
421
  for (const a of applied)
298
- byToken.set(a.token, (byToken.get(a.token) ?? 0) + 1);
422
+ byToken.set(a.token, (byToken.get(a.token) ?? 0) + a.occurrences);
299
423
  const ranked = [...byToken.entries()].sort((a, b) => b[1] - a[1]);
300
424
  const SHOWN = 6;
301
425
  for (const [token, n] of ranked.slice(0, SHOWN))
@@ -22,8 +22,28 @@ const CLASS_LITERAL = /className=(?:"([^"]{0,400})"|\{\s*"([^"]{0,400})"\s*\})/g
22
22
  const CLASS_TEMPLATE = /className=\{\s*`([^`]{0,400})`\s*\}/g;
23
23
  /** `className={algo}` sem literal nenhum: não existe na fonte. */
24
24
  const CLASS_RUNTIME = /className=\{\s*([A-Za-z_$][\w$.[\]]{0,60})\s*\}/g;
25
- /** Um literal dentro de `cn("…", …)` e amigos. */
26
- const CALL_LITERAL = /\b(?:cn|clsx|classNames|cx|twMerge|twJoin|classcat)\(([^)]{0,600})\)/g;
25
+ /**
26
+ * A ABERTURA de `cn("…", …)` e amigos - o FIM sai por parêntese balanceado, e não por regex.
27
+ *
28
+ * O QUE ACONTECIA: o corpo era `([^)]{0,600})`, que para no PRIMEIRO `)`. Basta uma chamada aninhada
29
+ * - `cn("flex", buttonVariants({ size }), "p-4")` - para tudo o que vem depois dela desaparecer da
30
+ * conta. Não é o teto de 600: é o parêntese.
31
+ *
32
+ * MEDIDO EM TRÊS POPULAÇÕES (05/09, classes que o coletor não via por causa do corte):
33
+ *
34
+ * codelevel/packages/ui 21 de 33 chamadas truncadas de 83
35
+ * frontend-hub/packages/ui 41 de 17 chamadas truncadas de 78
36
+ * frontend-hub/apps/web-dashboard 1 de 1 chamada truncada de 47
37
+ *
38
+ * E o que sumia não era resto: `shadow-[0_10px_30px_-10px_rgba(…)]`, `data-[starting-style]:…`,
39
+ * `bg-[var(--color-lightgray-300)]` - as classes mais ricas do repositório, que são exatamente as
40
+ * que o cliente quer ver contadas. Num ledger cuja promessa é que nada é descartado em silêncio,
41
+ * elas não eram lidas E não eram "não lidas": não existiam.
42
+ *
43
+ * É O MESMO PRECEDENTE do `sx` e do `styled` três blocos abaixo, e pela mesma razão: um regex com
44
+ * teto escolhe a formatação do arquivo de outra pessoa.
45
+ */
46
+ const CALL_OPEN = /\b(?:cn|clsx|classNames|cx|twMerge|twJoin|classcat)\(/g;
27
47
  const STRING_IN_CALL = /"([^"]{0,300})"|'([^']{0,300})'/g;
28
48
  /** `style={{ … }}`. */
29
49
  const INLINE = /style=\{\{([^}]{0,400})\}\}/g;
@@ -72,6 +92,7 @@ const MODULE_REF = /\$\{\s*[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*\s*\}/g;
72
92
  *
73
93
  * status === "success" ? "bg-success-500" : ... "success" é OPERANDO da comparação
74
94
  * // ... to prevent the "short width" issue "short width" está num COMENTÁRIO
95
+ * cn(iconVariants({ animation: … : "none" }), …) "none" é ARGUMENTO de outra chamada
75
96
  *
76
97
  * MEDIDO EM 11/08 no repo real, sobre as 26 declarações que o censo listava como `value-not-read`:
77
98
  *
@@ -93,6 +114,38 @@ function blankComments(body) {
93
114
  function isComparedTo(body, index) {
94
115
  return /(?:===|!==|==|!=)\s*$/.test(body.slice(0, index));
95
116
  }
117
+ /** Um utilitário de classe abrindo parêntese, para saber QUEM abriu a chamada mais interna. */
118
+ const OPENS_A_CLASS_CALL = /\b(?:cn|clsx|classNames|cx|twMerge|twJoin|classcat)\($/;
119
+ /**
120
+ * O LITERAL É ARGUMENTO DE OUTRA CHAMADA, e argumento de outra chamada não é classe.
121
+ *
122
+ * `cn(iconVariants({ animation: shouldAnimate ? animation : "none" }), className)` põe `"none"`
123
+ * dentro do `cn`, mas ele não veste nada: é o valor que a função de variantes recebe para escolher a
124
+ * classe. A esteira listava `none` como uma classe que não conseguiu ler, e o cliente ia procurar no
125
+ * código dele um estilo que ele nunca escreveu - o mesmo custo do operando de `===` logo acima.
126
+ *
127
+ * MEDIDO EM TRÊS POPULAÇÕES (05/09, sobre os literais que já passam pelos dois guards anteriores):
128
+ *
129
+ * codelevel/packages/ui 3 de 83 `"none"` em Card, Icon, Typography
130
+ * frontend-hub/packages/ui 0 de 123
131
+ * frontend-hub/apps/web-dashboard 0 de 68
132
+ *
133
+ * QUEM ABRIU A CHAMADA DECIDE, e não o simples fato de haver uma. Um utilitário de classe aninhado
134
+ * (`cn(clsx("flex"))`) continua vestindo, e o que está dentro dele É classe - sem esta metade, o
135
+ * conserto de um falso positivo criaria um falso negativo. Nas três populações essa forma dá N=0
136
+ * hoje; ela está aqui porque aninhar utilitário é forma de qualquer projeto, e o `CALL_LITERAL` só
137
+ * casa a chamada de fora.
138
+ */
139
+ function isArgumentOfAnotherCall(body, index) {
140
+ const open = [];
141
+ for (let i = 0; i < index; i++) {
142
+ if (body[i] === "(")
143
+ open.push(!OPENS_A_CLASS_CALL.test(body.slice(0, i + 1)));
144
+ else if (body[i] === ")")
145
+ open.pop();
146
+ }
147
+ return open[open.length - 1] === true;
148
+ }
96
149
  export function fragmentsOfSource(file, source) {
97
150
  const out = [];
98
151
  const at = (index) => source.slice(0, index).split("\n").length;
@@ -127,15 +180,28 @@ export function fragmentsOfSource(file, source) {
127
180
  * OS LITERAIS DENTRO DE UMA CHAMADA. `cn("flex p-4", cond && "gap-2")` tem dois, e os dois são
128
181
  * classe escrita à mão - a forma mais comum do repo dele (113 arquivos).
129
182
  */
130
- for (const call of source.matchAll(CALL_LITERAL)) {
131
- const body = blankComments(call[1] ?? "");
132
- const base = (call.index ?? 0) + (call[0].length - body.length - 1);
183
+ /**
184
+ * SÓ A CHAMADA MAIS EXTERNA - senão um utilitário aninhado conta os literais dele duas vezes.
185
+ *
186
+ * Enquanto o corpo parava no primeiro `)`, um `clsx(` dentro de um `cn(` era uma segunda match e
187
+ * ninguém percebia. Com o corpo inteiro, o de fora já cobre o de dentro.
188
+ */
189
+ let until = 0;
190
+ for (const call of source.matchAll(CALL_OPEN)) {
191
+ const from = (call.index ?? 0) + call[0].length;
192
+ if (from < until)
193
+ continue;
194
+ until = closingParen(source, from);
195
+ const body = blankComments(source.slice(from, until));
196
+ const base = from;
133
197
  for (const s of body.matchAll(STRING_IN_CALL)) {
134
198
  const text = s[1] ?? s[2] ?? "";
135
199
  if (!LOOKS_LIKE_CLASS.test(text))
136
200
  continue;
137
201
  if (isComparedTo(body, s.index ?? 0))
138
202
  continue;
203
+ if (isArgumentOfAnotherCall(body, s.index ?? 0))
204
+ continue;
139
205
  push("class", base + (s.index ?? 0), text);
140
206
  }
141
207
  }
@@ -151,6 +217,19 @@ export function fragmentsOfSource(file, source) {
151
217
  }
152
218
  return out;
153
219
  }
220
+ /** O fim da chamada aberta em `from`, contando parênteses. Sem fechamento, o fim do arquivo. */
221
+ function closingParen(source, from) {
222
+ let depth = 1;
223
+ let i = from;
224
+ while (i < source.length && depth > 0) {
225
+ if (source[i] === "(")
226
+ depth += 1;
227
+ else if (source[i] === ")")
228
+ depth -= 1;
229
+ i += 1;
230
+ }
231
+ return depth === 0 ? i - 1 : source.length;
232
+ }
154
233
  /** O fim de um objeto aberto com duas chaves - `sx={{ … }}` fecha em duas. */
155
234
  function balanced(source, from, close) {
156
235
  let depth = 2;
@@ -680,7 +680,7 @@ export const CHECKER_SINCE = "0.16.378";
680
680
  * posição de conteúdo. Um censo medido antes desta versão conta como deriva o que a folha do app
681
681
  * nem alcança - e o número do cliente é a promessa.
682
682
  */
683
- export const READER_SINCE = "0.16.378";
683
+ export const READER_SINCE = "0.16.381";
684
684
  /**
685
685
  * O QUE ESTÁ INSTALADO AQUI FICOU PARA TRÁS - e as DUAS condições que fazem isso ser verdade.
686
686
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.379",
3
+ "version": "0.16.381",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {