synthesisui 0.16.228 → 0.16.233

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.
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { readFile, writeFile } from "node:fs/promises";
27
27
  import { join } from "node:path";
28
+ import { nameToWrite } from "./scan.js";
28
29
  /**
29
30
  * A substituição numa linha só, e ela é deliberadamente burra.
30
31
  *
@@ -56,7 +57,15 @@ export function planFix(d, read) {
56
57
  * literal no texto que o primeiro já deixou.
57
58
  */
58
59
  for (const f of d.findings) {
59
- if (!f.token) {
60
+ /**
61
+ * O NOME DELE GANHA - ver `nameToWrite` em `scan.ts`.
62
+ *
63
+ * `--fix --write` edita o arquivo DELE, então o nome que entra ali é o do vocabulário dele
64
+ * quando existe um. Era aqui que a troca virava renomeação: 737 das 995 sugestões medidas no
65
+ * repo vivo escreviam `--ds-*` sobre um valor que uma variável dele já nomeia.
66
+ */
67
+ const name = nameToWrite(f);
68
+ if (!name) {
60
69
  skipped.push({
61
70
  file: f.file,
62
71
  line: f.line,
@@ -73,7 +82,23 @@ export function planFix(d, read) {
73
82
  * `var(--ds-typography-scale-h1-font-size)` no repo do dono (05/08) - um canto que se move no dia
74
83
  * em que alguém edita a escala de tipo. Medido lá: 14 de 129, e 0 de 76 na biblioteca dele.
75
84
  */
76
- if (f.crossFamily) {
85
+ /**
86
+ * O COMPRIMENTO QUE SEGUE A FONTE É DECISÃO DELE - ver `Finding.fontRelative`.
87
+ *
88
+ * `1em` e o degrau `1rem` do sistema são o mesmo número e não necessariamente o mesmo pixel. O
89
+ * comando MOSTRA a troca, porque sem ela não há conversa; escrevê-la sozinho em 933 lugares seria
90
+ * mudar o layout dele em silêncio por uma equivalência que a gente não pode conferir.
91
+ */
92
+ if (f.fontRelative) {
93
+ skipped.push({
94
+ file: f.file,
95
+ line: f.line,
96
+ literal: f.literal,
97
+ because: "font-relative",
98
+ });
99
+ continue;
100
+ }
101
+ if (f.crossFamily && !f.theirToken) {
77
102
  skipped.push({
78
103
  file: f.file,
79
104
  line: f.line,
@@ -100,7 +125,7 @@ export function planFix(d, read) {
100
125
  continue;
101
126
  const idx = f.line - 1;
102
127
  const current = body[idx];
103
- const swapped = current === undefined ? null : swap(current, f.literal, f.token);
128
+ const swapped = current === undefined ? null : swap(current, f.literal, name);
104
129
  if (swapped === null) {
105
130
  /** O arquivo mudou desde a medição, ou o literal já foi trocado por outro achado. */
106
131
  skipped.push({
@@ -116,7 +141,7 @@ export function planFix(d, read) {
116
141
  file: f.file,
117
142
  line: f.line,
118
143
  literal: f.literal,
119
- token: f.token,
144
+ token: name,
120
145
  });
121
146
  }
122
147
  for (const [file, body] of lines) {
@@ -154,11 +179,15 @@ export function describeFix(result, dry) {
154
179
  const coincidence = skipped.filter((s) => s.because === "cross-family").length;
155
180
  const unread = skipped.filter((s) => s.because === "unreadable").length;
156
181
  const decisions = skipped.filter((s) => s.because === "no-token").length;
182
+ /** O `em` que o comando mostra e não escreve - ver o guard em `planFix`. */
183
+ const relative = skipped.filter((s) => s.because === "font-relative").length;
157
184
  const lines = [];
158
185
  if (applied.length === 0) {
159
186
  lines.push(decisions > 0
160
187
  ? `Nothing to apply. All ${decisions} findings are values your system has no name for - those are design decisions, not fixes.`
161
- : "Nothing to apply.");
188
+ : relative > 0
189
+ ? `Nothing to apply. All ${relative} findings are lengths in \`em\`, which follow the element's font size - swapping them can move the layout, so that call is yours.`
190
+ : "Nothing to apply.");
162
191
  return lines;
163
192
  }
164
193
  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"}.`);
@@ -169,6 +198,12 @@ export function describeFix(result, dry) {
169
198
  .sort((a, b) => b[1] - a[1])
170
199
  .slice(0, 6))
171
200
  lines.push(` var(${token}) · ${n} time${n === 1 ? "" : "s"}`);
201
+ /**
202
+ * DITO SEMPRE QUE ACONTECE - senão o número de "trocado" some da conta sem explicação, e a pessoa
203
+ * conclui que o comando falhou onde ele se recusou de propósito.
204
+ */
205
+ if (relative > 0)
206
+ lines.push(` ${relative} more ${relative === 1 ? "is a length" : "are lengths"} in \`em\`, which follow the element's font size - your system names the same number, but the pixel may differ. Left for you to decide.`);
172
207
  if (moved > 0)
173
208
  lines.push(`${moved} line${moved === 1 ? "" : "s"} changed since the scan and ${moved === 1 ? "was" : "were"} left alone - run it again.`);
174
209
  if (coincidence > 0)
@@ -10,6 +10,15 @@
10
10
  * takes eight seconds and needs a build step is a diagnosis nobody runs.
11
11
  */
12
12
  import { normalizeValue, tokenMatch } from "./tokens.js";
13
+ /**
14
+ * O NOME QUE SE ESCREVE NO ARQUIVO DELE - e existe uma função só para isto por um motivo.
15
+ *
16
+ * Seis lugares decidem o que a pessoa vê ou o que o `--fix` grava: o relatório, o plano, o hook, o
17
+ * MCP, a aplicação e a contagem. Deixar cada um lembrar de preferir `theirToken` é o padrão do
18
+ * argumento opcional que se esquece - e o sintoma seria o pior possível: o comando aconselhando um
19
+ * nome e o hook aconselhando outro, sobre o mesmo arquivo.
20
+ */
21
+ export const nameToWrite = (f) => f.theirToken ?? f.token;
13
22
  /**
14
23
  * `next/og` renders JSX to a PNG on the server. There is no document, so there
15
24
  * is no `var(--ds-*)` to read: every colour in such a file MUST be a literal.
@@ -54,9 +63,35 @@ const OPEN = `["']?`;
54
63
  /** `rounded-[14px]`, `border-radius: 14px`, `borderRadius: "14px"`. Zero and
55
64
  * full pills are idiom, not drift - nobody tokenizes `0` or `9999px`. */
56
65
  const RADIUS = new RegExp(`(?:border-?[Rr]adius\\s*:\\s*${OPEN}|rounded(?:-[a-z]+)?-\\[)(-?\\d*\\.?\\d+)(px|rem|em)`, "g");
57
- /** Arbitrary spacing: `p-[18px]`, `gap-[7px]`, `margin: 18px`, `gap: "18px"`.
58
- * The JSX side also writes `paddingLeft`, `marginTop` and friends. */
59
- const SPACING = new RegExp(`(?:\\b[pmg](?:[trblxy])?-\\[|gap-\\[|(?:padding|margin|gap)(?:[A-Z][a-z]+)?\\s*:\\s*${OPEN})(-?\\d*\\.?\\d+)(px|rem)`, "g");
66
+ /**
67
+ * Arbitrary spacing: `p-[18px]`, `gap-[7px]`, `margin: 18px`, `gap: "18px"`.
68
+ * The JSX side also writes `paddingLeft`, `marginTop` and friends.
69
+ *
70
+ * `em` ENTROU EM 13/08, e a falta dele era uma palavra: o padrão do RAIO logo acima já aceitava as
71
+ * três unidades e o do espaçamento aceitava duas. Não era doutrina, era assimetria - e o custo dela é
72
+ * o pior tipo, porque um valor que a varredura não vê não aparece nem como lacuna.
73
+ *
74
+ * Medido no repositório real, o que estava invisível:
75
+ *
76
+ * 933 espaçamentos em `em` 300×0.5em · 276×1em · 116×0.25em
77
+ * 144 raios em `em` lidos, mas nunca nomeados (ver `normalizeValue`)
78
+ * 325 arquivos
79
+ *
80
+ * O dono achou pelo caminho que a gente não quer que ele precise usar (13/08): a skill abriu o
81
+ * `.scss` por conta própria e listou quatro `em` que o comando tinha acabado de chamar de limpo -
82
+ * *"não chegou nem a entrar no css, acho isso errado"*.
83
+ *
84
+ * E O KEBAB VEIO NA MESMA PUXADA, escondido atrás do primeiro. O sufixo aceito era só o camelCase
85
+ * do JSX (`paddingLeft`), então `padding-right: 1em` - a forma que TODO css escreve - não casava com
86
+ * nada. Foram os quatro `em` daquele mesmo arquivo que denunciaram: com `em` já no padrão, um
87
+ * continuou aparecendo e três não.
88
+ *
89
+ * 1175 `padding-*`/`margin-*` em kebab, em 335 arquivos do repositório real
90
+ *
91
+ * Duas gramáticas para a mesma propriedade é o tipo de coisa que só aparece quando alguém aponta
92
+ * para um arquivo e conta na mão.
93
+ */
94
+ const SPACING = new RegExp(`(?:\\b[pmg](?:[trblxy])?-\\[|gap-\\[|(?:padding|margin|gap)(?:[A-Z][a-z]+|(?:-[a-z]+)+)?\\s*:\\s*${OPEN})(-?\\d*\\.?\\d+)(px|rem|em)`, "g");
60
95
  /** A font stack written by hand rather than taken from the type scale. */
61
96
  const FONT = /font-family\s*:\s*([^;}\n]+)/g;
62
97
  /**
@@ -370,11 +405,22 @@ function scanCore(file, source, table) {
370
405
  // The kind is already known here, and without passing it the lookup
371
406
  // answers a `gap` with a radius token.
372
407
  const match = tokenMatch(table, literal, kind);
408
+ /**
409
+ * O NOME DELE VEM DA MESMA CHAVE QUE A NOSSA FAMÍLIA VALIDOU - ver `theirNames`.
410
+ *
411
+ * A chave carrega o `kind` porque o valor sozinho não decide: `4px` é `--radius-xs` e
412
+ * `--spacing` no vocabulário dele, e qual dos dois está certo depende de onde o literal está.
413
+ */
414
+ const theirs = table.aliases.get(`${kind}:${normalizeValue(literal, table.rootPx)}`);
373
415
  findings.push({
374
416
  kind,
375
417
  line: at,
376
418
  literal,
377
419
  token: match?.token ?? null,
420
+ ...(/(?<!r)em$/i.test(literal.trim())
421
+ ? { fontRelative: true }
422
+ : {}),
423
+ ...(theirs ? { theirToken: theirs } : {}),
378
424
  /**
379
425
  * A COINCIDÊNCIA VIAJA COM O ACHADO - ver `tokenMatch`.
380
426
  *
@@ -562,6 +608,8 @@ export function diagnose(files) {
562
608
  byLiteral.set(key, {
563
609
  kind: f.kind,
564
610
  token: f.token,
611
+ ...(f.theirToken ? { theirToken: f.theirToken } : {}),
612
+ ...(f.fontRelative ? { fontRelative: true } : {}),
565
613
  count: 1,
566
614
  files: new Set([f.file]),
567
615
  ...(f.crossFamily ? { crossFamily: true } : {}),
@@ -573,6 +621,8 @@ export function diagnose(files) {
573
621
  literal: key.slice(key.indexOf(":") + 1),
574
622
  kind: v.kind,
575
623
  token: v.token,
624
+ ...(v.theirToken ? { theirToken: v.theirToken } : {}),
625
+ ...(v.fontRelative ? { fontRelative: true } : {}),
576
626
  count: v.count,
577
627
  files: v.files.size,
578
628
  ...(v.crossFamily ? { crossFamily: true } : {}),
@@ -585,7 +635,15 @@ export function diagnose(files) {
585
635
  files: files.filter((f) => f.findings.length > 0 || (f.phantoms?.length ?? 0) > 0),
586
636
  findings: flat,
587
637
  counts,
588
- named: flat.filter((f) => f.token).length,
638
+ /**
639
+ * QUANTOS JÁ TÊM NOME NESTE REPOSITÓRIO - e o dele conta.
640
+ *
641
+ * Este número é a promessa que a tela faz ("9 of those have a name waiting"), e enquanto ele
642
+ * contava só os nossos, 852 achados que o repositório DELE nomeia saíam como "the system has no
643
+ * name for it". Uma promessa medida por metade do vocabulário que existe é uma promessa menor
644
+ * que a verdade.
645
+ */
646
+ named: flat.filter((f) => nameToWrite(f)).length,
589
647
  tokenUses,
590
648
  phantomUses,
591
649
  coverage: total === 0 ? 100 : Math.round(((tokenUses + ownUses) / total) * 100),
@@ -0,0 +1,175 @@
1
+ import { DS_FAMILY, familySays, normalizeValue, } from "./tokens.js";
2
+ /**
3
+ * COMO ELE CHAMA O VALOR - para a gente parar de renomear as variáveis dele.
4
+ *
5
+ * O doctor sempre soube dizer o que o sistema chama de `#888`. O que ele fazia com isso é que estava
6
+ * errado: mandava trocar por `var(--ds-color-gray-400)` num repositório que declara
7
+ * `--color-lightgray-700: #888888` desde antes de a gente existir. `--fix --write` escrevia o NOSSO
8
+ * nome dentro do arquivo dele.
9
+ *
10
+ * Medido no repositório vivo em 13/08, repo inteiro:
11
+ *
12
+ * 141 tokens que ELE declara
13
+ * 181 tokens que o sistema declara
14
+ * 114 dos nossos seguram um valor que um dos dele também segura
15
+ *
16
+ * 995 achados com nome nosso
17
+ * 737 deles (74%) renomeavam uma variável que ele já tem
18
+ *
19
+ * E o grupo que saía como "the system has no name for it":
20
+ *
21
+ * 5383 sem nome nosso
22
+ * 852 ...mas o repositório DELE nomeia (843 cor · 8 tipo · 1 motion)
23
+ *
24
+ * A LEI JÁ EXISTIA, num sentido só. `absorb-plan.ts` diz, sobre a direção repo -> sistema: *"quando o
25
+ * vocabulário DELES já nomeia o valor, o nome é o deles"*. Na direção sistema -> repo a gente fazia o
26
+ * contrário. É a mesma lei 11 um nível abaixo da cor: não reescrever o vocabulário dele.
27
+ *
28
+ * ESTE MAPA NÃO É UM ARQUIVO, e é de propósito (dono, 13/08: *"talvez essas informações possam vir do
29
+ * MCP para não levar isso para o usuário ficar vendo"*). Ele é derivado das folhas de estilo dele a
30
+ * cada rodada - 686 folhas e 797KB no repo real, 4ms no hook inteiro (80ms -> 84ms, medido ponta a
31
+ * ponta), e o `absorb` já pagava essa varredura hoje. Um
32
+ * mapa gravado envelhece no dia em que ele renomeia um token, e aí a gente sugere um nome que não
33
+ * existe mais. O MCP ganha de graça, porque `check_file` roda este mesmo doctor.
34
+ *
35
+ * E ele nunca aparece na tela: o que aparece é o nome DELE, no lugar onde antes aparecia o nosso.
36
+ */
37
+ /**
38
+ * QUANDO A FORMA DO VALOR JÁ DECIDE A FAMÍLIA - e é isto que a segunda porta pede.
39
+ *
40
+ * A porta admitia só COR, e o motivo estava certo pela metade: o que impede um comprimento de entrar
41
+ * é a AMBIGUIDADE (`24px` pode ser raio, espaçamento ou tamanho de fonte, e sem um token nosso não há
42
+ * o que decidisse), não o fato de ser cor. Uma pilha de fontes não é ambígua - `'Figtree', sans-serif`
43
+ * não pode ser um espaçamento -, e uma duração também não: `s` e `ms` só existem em motion.
44
+ *
45
+ * Então a regra é a forma, e o comprimento continua de fora, declarado. Medido no repositório real em
46
+ * 14/08: 7 achados em 6 arquivos saíam como "the system has no name for it" sobre a família de fontes
47
+ * que o próprio css dele nomeia `--dashboard-font-family`. É pouco, e é o único grupo que sobrou do
48
+ * item que eu tinha nomeado - os `em` saíram na leva anterior.
49
+ */
50
+ const UNAMBIGUOUS = {
51
+ /** `#rrggbbaa`, que é onde toda a família de dialetos cai. */
52
+ color: /^#[0-9a-f]{8}$/,
53
+ /** `s` e `ms` não aparecem em nenhuma outra família do contrato. */
54
+ motion: /^-?[\d.]+ms$/,
55
+ /** Uma pilha de fontes: tem vírgula, ou é uma das palavras genéricas do CSS. */
56
+ font: /,|^(?:serif|sans-serif|monospace|cursive|fantasy|system-ui)$/,
57
+ };
58
+ const segments = (name) => name.replace(/^--/, "").split("-");
59
+ /**
60
+ * QUAL DOS NOMES DELE, quando mais de um segura o mesmo valor - e são 18 no repo real.
61
+ *
62
+ * O empate não é decorativo: `4px` é `--radius-xs` E `--spacing` lá dentro, `24px` é `--text-h2` E
63
+ * `--spacing-lg`. Escolher errado escreve um token de tipo numa posição de espaçamento, que é
64
+ * exatamente o erro de categoria que o `crossFamily` foi criado para impedir do nosso lado.
65
+ *
66
+ * Três critérios, nesta ordem, e todos LIDOS do repositório dele:
67
+ *
68
+ * 1. quantos segmentos ele compartilha com o nosso token daquela família
69
+ * `--ds-radius-xs` puxa `--radius-xs` (2) e não `--spacing` (0)
70
+ * 2. a convenção dominante dele - o primeiro segmento mais frequente entre os tokens dele
71
+ * no repo real, `--color-*` (57) contra `--dashboard-*` (25)
72
+ * 3. ordem de declaração, que é o desempate que sempre existe e nunca inventa
73
+ */
74
+ function pick(candidates, ours, convention) {
75
+ const mine = ours ? new Set(segments(ours)) : null;
76
+ let best = candidates[0];
77
+ let bestScore = [-1, -1];
78
+ for (const name of candidates) {
79
+ const parts = segments(name);
80
+ const shared = mine ? parts.filter((p) => mine.has(p)).length : 0;
81
+ const score = [
82
+ shared,
83
+ convention.get(parts[0] ?? "") ?? 0,
84
+ ];
85
+ if (score[0] > bestScore[0] ||
86
+ (score[0] === bestScore[0] && score[1] > bestScore[1])) {
87
+ best = name;
88
+ bestScore = score;
89
+ }
90
+ }
91
+ return best;
92
+ }
93
+ /**
94
+ * O MAPA DE UMA RODADA: `<natureza>:<valor normalizado>` -> como ELE chama.
95
+ *
96
+ * A chave carrega a natureza porque o valor sozinho não decide: o mesmo `4px` tem dois nomes no
97
+ * vocabulário dele, e qual dos dois está certo depende de o literal estar num raio ou num
98
+ * espaçamento. É a mesma razão pela qual `tokenMatch` recebe o `kind`.
99
+ *
100
+ * DUAS PORTAS, e a segunda é o que faz o número dobrar:
101
+ *
102
+ * o sistema nomeia o valor a NOSSA família valida a natureza, e o nome dele entra
103
+ * o sistema NÃO nomeia entra o valor cuja FORMA já decide a família - ver `UNAMBIGUOUS`
104
+ *
105
+ * O que fica de fora da segunda porta é o COMPRIMENTO, e ele fica por um motivo e não por descuido:
106
+ * `24px` pode ser raio, espaçamento ou tamanho de fonte, e sem um token nosso não há o que validasse.
107
+ * Esses continuam saindo como "no name for it", que é verdade e é o caminho do `absorb`.
108
+ */
109
+ export function theirNames(ours, theirs) {
110
+ const out = new Map();
111
+ if (theirs.byName.size === 0)
112
+ return out;
113
+ /** A convenção dominante dele, contada e não suposta - ver `pick`. */
114
+ const convention = new Map();
115
+ for (const name of theirs.byName.keys()) {
116
+ const head = segments(name)[0] ?? "";
117
+ convention.set(head, (convention.get(head) ?? 0) + 1);
118
+ }
119
+ for (const [kind, prefix] of Object.entries(DS_FAMILY)) {
120
+ for (const [name, raw] of ours.byName) {
121
+ if (!name.startsWith(prefix))
122
+ continue;
123
+ const value = normalizeValue(raw, ours.rootPx);
124
+ const key = `${kind}:${value}`;
125
+ if (out.has(key))
126
+ continue;
127
+ const candidates = theirs.byValue.get(value);
128
+ if (!candidates || candidates.length === 0)
129
+ continue;
130
+ /**
131
+ * E A CATEGORIA DO NOME DELE TEM QUE FECHAR - senão o nosso nome fica, e ele está certo.
132
+ *
133
+ * O NOSSO prefixo carrega a família por construção; o dele não carrega nada garantido. Quando a
134
+ * FORMA do valor já decide (hex, `ms`, pilha de fontes) não há o que proteger e o nome dele
135
+ * entra. Quando o valor é um comprimento cru, `12px` pode ser `--text-caption` ou `--spacing-sm`
136
+ * no vocabulário dele, e escolher errado escreve um token de tipo num `border-radius`.
137
+ *
138
+ * Medido no repositório real em 14/08, antes desta linha: 290 sugestões com a categoria trocada,
139
+ * 66 delas gravadas por um `--fix --write`, e a pior na PRIMEIRA página do relatório -
140
+ * `0.25em → --radius-xs` em 116 arquivos. Ver `FAMILY_WORDS`.
141
+ *
142
+ * Recusar aqui não perde informação: sem alias, `nameToWrite` cai no NOSSO token, que é da
143
+ * família certa porque foi o prefixo dela que o trouxe a este laço.
144
+ */
145
+ const formDecides = Object.values(UNAMBIGUOUS).some((f) => f.test(value));
146
+ const usable = formDecides
147
+ ? candidates
148
+ : candidates.filter((n) => familySays(kind, n));
149
+ if (usable.length === 0)
150
+ continue;
151
+ out.set(key, pick(usable, name, convention));
152
+ }
153
+ }
154
+ for (const [value, candidates] of theirs.byValue) {
155
+ for (const [kind, form] of Object.entries(UNAMBIGUOUS)) {
156
+ if (!form.test(value))
157
+ continue;
158
+ const key = `${kind}:${value}`;
159
+ if (out.has(key))
160
+ continue;
161
+ out.set(key, pick(candidates, null, convention));
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+ /**
167
+ * A TABELA COM O VOCABULÁRIO DELE DENTRO - um passo, e é este que os comandos chamam.
168
+ *
169
+ * `ours` continua sendo a lei: é ela que diz se `2rem` é tipo ou raio, e é dela que sai a cobertura.
170
+ * A dele decide só COMO SE ESCREVE. Separar as duas é o mesmo desenho que o `absorb` já usa
171
+ * ("DUAS TABELAS, e é a diferença entre elas que define o trabalho").
172
+ */
173
+ export function withTheirNames(ours, theirs) {
174
+ return { ...ours, aliases: theirNames(ours, theirs) };
175
+ }
@@ -31,6 +31,7 @@ export const EMPTY_TABLE = {
31
31
  byValue: new Map(),
32
32
  rootPx: DEFAULT_ROOT_PX,
33
33
  rootFrom: null,
34
+ aliases: new Map(),
34
35
  declared: new Set(),
35
36
  keyframes: new Set(),
36
37
  };
@@ -120,10 +121,19 @@ export function normalizeValue(raw, rootPx) {
120
121
  * A FAMÍLIA CONTINUA MANDANDO: `4px` passa a casar com `--ds-radius-xs` E com `--ds-spacing-3xs`,
121
122
  * e é `tokenMatch` quem escolhe pelo `kind` - um raio não vira espaçamento dentro de um `gap`.
122
123
  */
123
- const len = /^(-?\d*\.?\d+)(px|rem)$/.exec(v);
124
+ /**
125
+ * `em` CAI AQUI JUNTO, e o que impede isso de virar uma suposição é o achado, não o normalizador.
126
+ *
127
+ * Converter `1em` por `rootPx` é certo sempre que a fonte do elemento é a da raiz, e errado quando
128
+ * um ancestral a mudou - e isto aqui não tem como saber qual dos dois. Ele converte para a
129
+ * CONVERSA existir (sem isto, `1em` não casa com degrau nenhum e sai como "o sistema não nomeia
130
+ * isto", sobre um valor que o sistema nomeia); quem impede a troca automática é `Finding.fontRelative`,
131
+ * que o `--fix` recusa. Ver `scan.ts`.
132
+ */
133
+ const len = /^(-?\d*\.?\d+)(px|r?em)$/.exec(v);
124
134
  if (len) {
125
135
  const n = Number.parseFloat(len[1]);
126
- const px = len[2] === "rem" ? n * rootPx : n;
136
+ const px = len[2] === "px" ? n : n * rootPx;
127
137
  /** Arredonda o rastro binário: `0.35rem * 16` é 5.6000000000000005. */
128
138
  return `${Math.round(px * 1e4) / 1e4}px`;
129
139
  }
@@ -431,6 +441,8 @@ export function buildTable(input) {
431
441
  byValue,
432
442
  rootPx,
433
443
  rootFrom: input.rootFrom ?? null,
444
+ /** Vazio até `withTheirNames` ler o vocabulário dele - ver `their-names.ts`. */
445
+ aliases: new Map(),
434
446
  declared: parseDeclaredNames(input.css),
435
447
  keyframes: new Set([...input.css.matchAll(/@keyframes\s+([a-zA-Z0-9_-]+)/g)].map((m) => m[1])),
436
448
  };
@@ -492,13 +504,56 @@ export function nearestToken(table, literal) {
492
504
  * radius is one a person stops reading, and this one has exactly one job that
493
505
  * nobody else does: naming the right token.
494
506
  */
495
- const FAMILY = {
507
+ /** Exportado para `their-names.ts`, que precisa da MESMA divisão de família para validar a natureza
508
+ * de um nome dele - duas tabelas de família seriam duas leis. */
509
+ export const DS_FAMILY = {
496
510
  color: "--ds-color-",
497
511
  radius: "--ds-radius-",
498
512
  spacing: "--ds-spacing-",
499
513
  font: "--ds-typography-",
500
514
  motion: "--ds-motion-",
501
515
  };
516
+ /**
517
+ * AS PALAVRAS COM QUE O MUNDO NOMEIA CADA FAMÍLIA - e é isto que impede um erro de categoria.
518
+ *
519
+ * O NOSSO prefixo carrega a família (`--ds-spacing-md` é espaçamento por construção). O DELE não
520
+ * carrega nada garantido, e nada verificava: `--text-caption` e `--spacing-sm` podem segurar o mesmo
521
+ * `12px`, e sugerir o primeiro para um `border-radius` é escrever um token de tipo numa posição de
522
+ * raio. É exatamente o erro que o `crossFamily` foi criado para impedir do NOSSO lado, e ele passou
523
+ * a acontecer pelo lado dele quando o mapa de vocabulário nasceu.
524
+ *
525
+ * Medido no repositório real em 14/08, antes desta trava:
526
+ *
527
+ * 2680 achados com nome dele
528
+ * 1635 categoria concorda
529
+ * 290 categoria DISCORDA <- sugestão errada na tela
530
+ * 66 dessas o `--fix --write` GRAVARIA (o resto é `em`, que já tem trava)
531
+ * 755 nome sem palavra de categoria - e são todos cor (748) e fonte (7)
532
+ *
533
+ * A pior estava na primeira página: `0.25em → --radius-xs` em 116 arquivos, na lista das mais
534
+ * repetidas. E `4px → --radius-xs` num padding é uma escrita silenciosa, sem `em` para barrá-la.
535
+ *
536
+ * `text` está na lista de tipo porque `--text-h2` é a forma dominante no mundo real, e `transition`
537
+ * na de motion pelo mesmo motivo.
538
+ */
539
+ export const FAMILY_WORDS = {
540
+ color: ["color", "colour"],
541
+ radius: ["radius", "rounded", "corner"],
542
+ spacing: ["spacing", "space", "gap", "inset"],
543
+ font: ["font", "type", "text"],
544
+ motion: ["duration", "motion", "ease", "easing", "transition"],
545
+ };
546
+ /**
547
+ * O NOME DELE DIZ QUE É DESTA FAMÍLIA - por QUALQUER segmento, não só pelo primeiro.
548
+ *
549
+ * `--dashboard-radius-lg` é um raio e o primeiro segmento é o namespace dele. Olhar só o começo
550
+ * recusaria um nome certo por causa de um prefixo de produto, que é a metade oposta do mesmo erro.
551
+ */
552
+ export const familySays = (kind, name) => {
553
+ const words = FAMILY_WORDS[kind] ?? [];
554
+ const segments = name.replace(/^--/, "").toLowerCase().split("-");
555
+ return segments.some((seg) => words.includes(seg));
556
+ };
502
557
  /**
503
558
  * O token que carrega este valor, e SE ELE É DA MESMA FAMÍLIA.
504
559
  *
@@ -521,7 +576,7 @@ export function tokenMatch(table, literal, kind) {
521
576
  const hit = table.byValue.get(normalizeValue(literal, table.rootPx));
522
577
  if (!hit || hit.length === 0)
523
578
  return null;
524
- const prefix = kind ? FAMILY[kind] : undefined;
579
+ const prefix = kind ? DS_FAMILY[kind] : undefined;
525
580
  const family = prefix ? hit.filter((n) => n.startsWith(prefix)) : [];
526
581
  const pool = family.length > 0 ? family : hit;
527
582
  // Semantic roles name intent; primitives name a shelf. Prefer intent.
@@ -71,7 +71,16 @@
71
71
  * a prop, e 104 das 207 condições sem expressão nenhuma. Um `upgrade` anterior a esta versão reescreve
72
72
  * os componentes dele com a perda intacta.
73
73
  */
74
- export const MATERIALISER_SINCE = "0.16.220";
74
+ /**
75
+ * 0.16.220 -> 0.16.233 em 14/08: o componente materializado muda de bytes. A regra de contenção HTML
76
+ * era enumerada à mão em `elementFor` e a enumeração esquecia `heading`, `row` e `stack` - uma raiz de
77
+ * papel `action` com um heading no topo saía como `<button><h3>…</h3></button>`, que é marcação
78
+ * inválida embarcada num copy-paste. Agora sai `<article>`.
79
+ *
80
+ * Zero ocorrências no sistema real medido (todos os headings dele estão aninhados, e o `children > 0`
81
+ * já os pegava), então para ele o `upgrade` é no-op. A marca é sobre o caso geral.
82
+ */
83
+ export const MATERIALISER_SINCE = "0.16.233";
75
84
  /**
76
85
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
77
86
  *
@@ -96,7 +105,41 @@ export const MATERIALISER_SINCE = "0.16.220";
96
105
  * tem nome para isto" sobre um valor que o sistema dela nomeia - medido no repo real, 243 trocas
97
106
  * viraram 764 sobre os mesmos 4433 valores à mão.
98
107
  */
99
- export const CHECKER_SINCE = "0.16.223";
108
+ /**
109
+ * 0.16.223 -> 0.16.229 em 13/08, e o SIM mais forte que esta marca já teve: o hook passou a
110
+ * aconselhar o nome DELE. Um pin anterior lê o mesmo arquivo e manda escrever
111
+ * `var(--ds-color-gray-400)` num repositório que declara `--color-lightgray-700: #888888` - e a
112
+ * pessoa acaba com dois conselhos diferentes sobre o mesmo valor, porque o `doctor` de hoje diz o
113
+ * nome dela. Medido no repo real: 737 de 995 sugestões renomeavam uma variável dela, e outras 852
114
+ * saíam como "o sistema não nomeia isto" sobre valores que o repositório dela nomeia.
115
+ *
116
+ * É também a primeira vez que o hook VARRE as folhas de estilo, revisando a escolha declarada em
117
+ * `loadSystem` - medido ponta a ponta no repo real, 80ms -> 84ms, contra oferecer o nome errado.
118
+ */
119
+ /**
120
+ * 0.16.229 -> 0.16.230 em 13/08: `em` passou a ser um comprimento como os outros. O padrão do RAIO
121
+ * já aceitava as três unidades e o do ESPAÇAMENTO aceitava duas - assimetria, não doutrina - e um
122
+ * hook pinado antes disto lê o mesmo arquivo e não vê 933 espaçamentos e 144 raios, em 325 arquivos
123
+ * do repositório real. Não é medir menos: é chamar de limpo o que não é.
124
+ *
125
+ * A 0.16.229 nunca chegou ao npm, mas a marca sobe assim mesmo - uma marca que aponta para uma
126
+ * versão que ninguém pode instalar é uma marca que nunca dispara.
127
+ */
128
+ /**
129
+ * 0.16.230 -> 0.16.231 em 14/08: a segunda porta do mapa de vocabulário deixou de ser "só cor" e
130
+ * passou a ser "a forma que já decide a família" - uma pilha de fontes não pode ser um espaçamento, e
131
+ * `ms` só existe em motion. Um hook pinado antes disto olha o arquivo que a pessoa acabou de escrever
132
+ * e diz que o sistema não nomeia a fonte que o css dela nomeia. Medido no repositório real: as trocas
133
+ * com nome disponível foram de 1624 para 1631.
134
+ */
135
+ /**
136
+ * 0.16.231 -> 0.16.232 em 14/08, e este é o pior que esta marca já carregou: o pinado não erra por
137
+ * omissão, ele erra o CONSELHO. O mapa de vocabulário escolhia entre os nomes DELE sem conferir a
138
+ * categoria, então um hook pinado antes disto olha um `border-radius: 12px` recém-escrito e manda usar
139
+ * `var(--text-caption)` - um token de tipo numa posição de raio. Medido no repositório real: 290
140
+ * sugestões com a categoria trocada, 66 delas graváveis por um `--fix --write`.
141
+ */
142
+ export const CHECKER_SINCE = "0.16.232";
100
143
  /**
101
144
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
102
145
  *
@@ -117,7 +117,9 @@ export function describeScope(m) {
117
117
  ]
118
118
  : []),
119
119
  ];
120
- return `read from your census: ${parts.join(" · ")}`;
120
+ /** O ARQUIVO, e não a palavra: "census" é o nosso nome para a medição; `_synthesisui/census.json`
121
+ * é um caminho que ele abre. Ver a guarda de vocabulário em `doctor-intent.spec.ts`. */
122
+ return `read from _synthesisui/census.json: ${parts.join(" · ")}`;
121
123
  }
122
124
  /**
123
125
  * GRAVA ONDE ESTE SISTEMA FOI MEDIDO, no ponteiro dele.