synthesisui 0.16.418 → 0.16.419

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.
@@ -2,6 +2,7 @@ import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
3
  import { appendEvent } from "../doctor/ledger.js";
4
4
  import { diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
5
+ import { namesRemoved, previousText, rulesTouchedBy } from "../rule-touched.js";
5
6
  import { loadSystem } from "./doctor.js";
6
7
  const pass = () => ({ continue: true });
7
8
  const speak = (context) => ({
@@ -47,14 +48,80 @@ async function greet(root, rel) {
47
48
  "here it only speaks when something needs a name.",
48
49
  ].join("\n");
49
50
  }
51
+ /**
52
+ * A REGRA QUE ESTA EDIÇÃO APAGOU - e o que a plataforma diz em vez de calar.
53
+ *
54
+ * ═══ O CASO, medido em 12/09 ═══
55
+ *
56
+ * O agente dele apagou a serif itálica de seis lugares da landing do `codelevel` e nada avisou. A
57
+ * regra estava instalada, em disco, a dois diretórios da edição - a décima de vinte e cinco - e era
58
+ * literalmente sobre isso. Palavras dele ao abrir a etapa: *"o sistema fala antes do dano"*.
59
+ *
60
+ * ═══ POR QUE ISTO É UM BLOCO SEPARADO, E VEM PRIMEIRO ═══
61
+ *
62
+ * Acrescentar valor solto é DERIVA, e a resposta é o token. Apagar o que uma regra escrita exige é
63
+ * uma EMENDA AO SISTEMA, e a resposta é outra: ou a regra muda, ou o caso é uma exceção registrada.
64
+ * As duas saídas são decisão de uma PESSOA, e nenhuma delas é "escreva isto de volta".
65
+ *
66
+ * Se as duas informações saíssem na mesma lista, um agente que acabou de aprender *"lista →
67
+ * proponha o token"* leria a emenda com o mesmo reflexo - e é o mesmo defeito que a revisão de DX
68
+ * já tinha encontrado na lista dos valores sem nome.
69
+ *
70
+ * ═══ E NADA AQUI BLOQUEIA NEM BATIZA ═══
71
+ *
72
+ * O retorno continua sendo contexto: `continue: true` em todo caminho. E nenhum nome é proposto -
73
+ * a frase diz qual regra, qual nome declarado amarra a regra ao que saiu, e devolve a decisão.
74
+ */
75
+ async function amendment(root, rel, after, rules, declared) {
76
+ if (rules.length === 0)
77
+ return [];
78
+ const before = await previousText(root, rel);
79
+ if (before == null)
80
+ return [];
81
+ const touched = rulesTouchedBy(namesRemoved(before, after, declared), rules);
82
+ if (touched.length === 0)
83
+ return [];
84
+ return [
85
+ /**
86
+ * A FRASE FALA DO ARQUIVO, NUNCA DESTA EDIÇÃO - achado da revisão de QA no fecho.
87
+ *
88
+ * Ela dizia *"this edit removes…"*, e o "antes" é o último COMMIT: da segunda escrita em
89
+ * diante aquela edição não removeu nada, e a frase virava mentira enquanto se repetia. Dita
90
+ * sobre o ARQUIVO ela é verdadeira todas as vezes - e a repetição em si continua sendo dívida
91
+ * declarada em `steps/20-fora-do-escopo.md`.
92
+ */
93
+ `${rel} - this file no longer has what a rule of this system asks for.`,
94
+ "",
95
+ ...touched.flatMap(({ rule, through }) => [
96
+ ` ${through} is gone from this file, and the system says:`,
97
+ ` "${rule.text}"`,
98
+ ]),
99
+ "",
100
+ /**
101
+ * "DRIFT" É PALAVRA NOSSA, e o portão de vocabulário (`INV-VOC-05`) a barrou nesta linha -
102
+ * com razão: ela é o nome interno para um valor fora do sistema, e quem lê esta saída é o
103
+ * repositório DELE. A frase diz a mesma coisa com o que o produto já escreve duas linhas
104
+ * abaixo, "written by hand".
105
+ */
106
+ "That is an amendment to the system, not a value written by hand - so there is no token to put back.",
107
+ "Tell the person which it is: the rule changes, or this case is an exception worth recording.",
108
+ ];
109
+ }
50
110
  async function report(root, filePath) {
51
- const { table } = await loadSystem(root);
111
+ const { table, doctrines } = await loadSystem(root);
52
112
  if (table.byName.size === 0)
53
113
  return null;
54
- const src = await readFile(filePath, "utf8").catch(() => "");
55
- if (!src)
114
+ /**
115
+ * VAZIO NÃO É ILEGÍVEL - achado da revisão de QA no fecho, e a remoção mais destrutiva possível
116
+ * produzia a menor saída possível. `catch` devolve `null` quando o arquivo não pode ser lido;
117
+ * um arquivo TRUNCADO devolve `""`, e apagar todo o conteúdo de um arquivo governado apaga todas
118
+ * as regras dele de uma vez - que é exatamente quando falar importa mais.
119
+ */
120
+ const src = await readFile(filePath, "utf8").catch(() => null);
121
+ if (src == null)
56
122
  return null;
57
123
  const rel = relative(root, filePath);
124
+ const broke = await amendment(root, rel, src, doctrines.flatMap((doc) => doc.rules), table.declared).catch(() => []);
58
125
  const d = diagnose([scanSource(rel, src, table)]);
59
126
  const named = d.findings.filter((f) => nameToWrite(f));
60
127
  /**
@@ -84,7 +151,10 @@ async function report(root, filePath) {
84
151
  named: named.length,
85
152
  phantoms: phantoms.length,
86
153
  });
87
- if (named.length === 0 && phantoms.length === 0 && unnamed.length === 0)
154
+ if (broke.length === 0 &&
155
+ named.length === 0 &&
156
+ phantoms.length === 0 &&
157
+ unnamed.length === 0)
88
158
  return greet(root, rel);
89
159
  // A report IS the evidence the greeting exists to provide, so it counts as the
90
160
  // introduction. Otherwise a project whose first file had drift would get the
@@ -109,16 +179,31 @@ async function report(root, filePath) {
109
179
  * é "deixe como está" volta a ser dito na próxima escrita naquele arquivo, porque não existe
110
180
  * memória por linha e criar uma é comportamento novo. Está medido em `steps/11-fora-do-escopo.md`.
111
181
  */
182
+ /**
183
+ * A EMENDA VEM ANTES DO VALOR, e sozinha quando é a única coisa a dizer.
184
+ *
185
+ * Ela é a mais cara de ignorar: um valor solto se conserta depois, e uma regra apagada vira o
186
+ * próximo agente restaurando a serif citando a doutrina, com o dono achando que ele alucinou.
187
+ */
188
+ if (broke.length > 0 && named.length === 0 && phantoms.length === 0)
189
+ return broke.join("\n");
112
190
  const sample = unnamed
113
191
  .slice(0, 3)
114
192
  .map((f) => `line ${f.line} ${f.literal}`)
115
193
  .join(", ");
116
- if (named.length === 0 && phantoms.length === 0)
194
+ if (broke.length === 0 && named.length === 0 && phantoms.length === 0)
117
195
  return [
118
196
  `${rel} - ${unnamed.length} value${unnamed.length === 1 ? "" : "s"} here ${unnamed.length === 1 ? "has" : "have"} no name in this system, and nothing to replace ${unnamed.length === 1 ? "it" : "them"} with: ${sample}${unnamed.length > 3 ? `, +${unnamed.length - 3} more` : ""}.`,
119
197
  "Do not invent a name - leave them, or ask the person what they would call it.",
120
198
  ].join("\n");
121
- const lines = [`${rel} - checked against ${table.name ?? table.slug}.`];
199
+ /**
200
+ * E QUANDO HÁ AS DUAS COISAS, a emenda lidera o relatório - mesma razão: ela é a que ninguém
201
+ * conserta depois, e a lista de valores abaixo dela ensina a diferença entre deriva e emenda.
202
+ */
203
+ const lines = [
204
+ ...(broke.length > 0 ? [...broke, ""] : []),
205
+ `${rel} - checked against ${table.name ?? table.slug}.`,
206
+ ];
122
207
  if (named.length > 0) {
123
208
  lines.push("", "Values written by hand that this system already has a name for:", ...named
124
209
  .slice(0, 20)
@@ -169,8 +254,18 @@ export async function hook(opts) {
169
254
  const abs = resolve(root, file);
170
255
  // Our own installed artifacts are the answer, not the problem - and a file
171
256
  // outside the project is none of our business.
172
- const inside = !relative(root, abs).startsWith("..");
173
- if (!UI_FILE.test(abs) || !inside || abs.includes("_synthesisui")) {
257
+ /**
258
+ * O CAMINHO TESTADO É O RELATIVO - achado da revisão de QA no fecho.
259
+ *
260
+ * `abs.includes("_synthesisui")` olhava o caminho ABSOLUTO, então quem clonasse o projeto para
261
+ * qualquer diretório com esse nome no meio (`~/_synthesisui-demo/app`) tinha TODOS os arquivos
262
+ * pulados e um hook permanentemente mudo. O relativo já estava calculado uma linha acima.
263
+ */
264
+ const rel = relative(root, abs);
265
+ const inside = !rel.startsWith("..");
266
+ if (!UI_FILE.test(abs) ||
267
+ !inside ||
268
+ rel.split("/").includes("_synthesisui")) {
174
269
  process.stdout.write(`${JSON.stringify(pass())}\n`);
175
270
  return;
176
271
  }
@@ -344,7 +344,7 @@ export const COUNTED_DIFFERENTLY = "this run counts every length in a shorthand
344
344
  * e nós engolimos". Agora sai `line 12 #ff00aa`, sem uma única proposta de nome nosso. Um hook
345
345
  * pinado antes desta versão aconselha MENOS, que é a régua desta marca.
346
346
  */
347
- export const CHECKER_SINCE = "0.16.413";
347
+ export const CHECKER_SINCE = "0.16.419";
348
348
  /**
349
349
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
350
350
  *
@@ -0,0 +1,175 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const run = promisify(execFile);
4
+ /**
5
+ * O QUE ESTA EDIÇÃO APAGOU, E QUAL REGRA DELE ISSO TOCA.
6
+ *
7
+ * ═══ O CASO, medido em 12/09 ═══
8
+ *
9
+ * O agente dele apagou a serif itálica de seis lugares da landing do `codelevel`. A regra existia,
10
+ * instalada: *"A display headline breaks onto a line set in the serif, italic - that line carries
11
+ * the emphasis of the sentence, never the whole headline."* Nada avisou.
12
+ *
13
+ * Duas razões, e este arquivo é as duas. A checagem lia o ARQUIVO depois da escrita - e uma
14
+ * remoção é, por construção, aquilo que não está mais lá. E ela media VALOR SOLTO contra token,
15
+ * nunca regra.
16
+ *
17
+ * ═══ O ANTES VEM DO GIT, e não do payload do cliente ═══
18
+ *
19
+ * O hook recebe `tool_input.file_path` e o cliente PODE mandar o texto anterior - mas o formato é
20
+ * de cada cliente, muda entre versões, e um produto que se apoia nele funciona num editor e cala
21
+ * no seguinte. O git é o mesmo em todo lugar e responde a pergunta certa: *o que esta árvore
22
+ * removeu em relação ao último commit?* - que é exatamente o que ele vai empurrar.
23
+ *
24
+ * SEM GIT, SEM ARQUIVO NO HEAD, OU ARQUIVO NOVO: não há "antes", então não há remoção. Silêncio é
25
+ * a resposta correta, e não uma falha - um arquivo novo não apagou nada de ninguém.
26
+ *
27
+ * ═══ E O CASAMENTO É POR VOCABULÁRIO DECLARADO, nunca por palavra em inglês ═══
28
+ *
29
+ * A tentação é procurar as palavras da regra no diff. Isso casaria *"never"* com qualquer coisa e
30
+ * transformaria a checagem numa fonte de falso positivo - e um falso positivo custa mais que
31
+ * silêncio aqui, porque ele treina a pessoa a ignorar a saída e depois a desinstalar o hook.
32
+ *
33
+ * Então os dois lados são amarrados pelo que o SISTEMA DELE DECLARA. Uma regra "toca" o que foi
34
+ * apagado quando um nome declarado aparece nos dois: no texto da regra e no que saiu do arquivo.
35
+ * No caso real: o sistema declara `--font-serif`, a regra fala em `serif`, e a edição removeu
36
+ * `font-serif` seis vezes.
37
+ */
38
+ /** Como o texto anterior era, ou `null` quando não há "antes" que se possa provar. */
39
+ export async function previousText(root, rel) {
40
+ try {
41
+ /**
42
+ * O `./` NÃO É ENFEITE, e a população do incidente escondia isto.
43
+ *
44
+ * `git show HEAD:<caminho>` resolve a partir do TOPO da árvore, nunca do `cwd`. No `codelevel`
45
+ * a raiz do git e a raiz do projeto coincidem, então os dois davam no mesmo. Num monorepo com
46
+ * o `_synthesisui` em `apps/landing/`, `HEAD:app/x.tsx` devolve o arquivo da RAIZ - outro
47
+ * arquivo -, e a checagem ou cala ou acusa uma remoção que ninguém fez.
48
+ *
49
+ * `HEAD:./<caminho>` resolve a partir do `cwd`, que é a raiz do projeto. Reproduzido em 12/09
50
+ * num repositório de teste com os dois arquivos no mesmo caminho relativo.
51
+ */
52
+ const { stdout } = await run("git", ["show", `HEAD:./${rel}`], {
53
+ cwd: root,
54
+ maxBuffer: 8 * 1024 * 1024,
55
+ });
56
+ return stdout;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * OS NOMES DECLARADOS QUE ESTE ARQUIVO DEIXOU DE REFERENCIAR.
64
+ *
65
+ * ═══ POR QUE NÃO É UM DIFF DE LINHAS ═══
66
+ *
67
+ * A primeira versão desta função comparava as LINHAS que sumiram, e o spec do A6 a derrubou na
68
+ * primeira execução: trocar uma palavra dentro de uma linha faz a linha inteira "sumir", então
69
+ * editar o texto de um heading acusava a remoção da família que continua ali, na mesma linha.
70
+ * Falso positivo é o defeito que esta checagem menos pode ter - ele treina a pessoa a ignorar a
71
+ * saída, e depois a desinstalar o hook.
72
+ *
73
+ * A pergunta certa é sobre PRESENÇA, não sobre texto: este arquivo referenciava `--font-serif` e
74
+ * não referencia mais? Ela é imune a reformatação, a mover código de lugar e a reescrever a frase
75
+ * em volta - e é exatamente o que uma regra do sistema fala sobre.
76
+ *
77
+ * O PREÇO, dito em voz alta: remover cinco de seis usos não é remoção - o nome continua no
78
+ * arquivo. É a troca certa. A regra fala sobre o sistema ter aquela decisão presente, e um uso que
79
+ * fica mantém a decisão presente; acusar por contagem transformaria toda refatoração num alarme.
80
+ */
81
+ export function namesRemoved(before, after, declared) {
82
+ const had = declaredNamesIn(before, declared);
83
+ if (had.length === 0)
84
+ return [];
85
+ const kept = new Set(declaredNamesIn(after, declared));
86
+ return had.filter((name) => !kept.has(name));
87
+ }
88
+ /**
89
+ * A FOLHA DE UM NOME DECLARADO - `--font-serif` → `serif`, `--ds-color-brand` → `brand`.
90
+ *
91
+ * É por ela que uma regra escrita em prosa encontra um nome: ninguém escreve *"a headline quebra
92
+ * numa linha em `--ds-typography-families-serif`"*, escreve *"in the serif"*.
93
+ */
94
+ const leafOf = (name) => name.replace(/^--/, "").split("-").pop() ?? "";
95
+ /**
96
+ * ═══ QUANDO UMA FOLHA IDENTIFICA - e a resposta sai do CORPUS DELE, não de uma lista nossa ═══
97
+ *
98
+ * A primeira versão disto era uma lista fechada de vinte e sete palavras em inglês (`body`,
99
+ * `size`, `color`…) mais um corte por tamanho. A revisão de contrato derrubou as duas coisas, e
100
+ * pelo motivo certo: um cliente que escreve o sistema dele em português (`--fonte-corpo` → `corpo`)
101
+ * não recebia filtragem nenhuma e pagava em falso positivo - o defeito exato que este módulo diz
102
+ * não poder ter. E o `>= 4` descartava folha curta e real: `--ds-color-ink` tem folha `ink`.
103
+ *
104
+ * A FORMA GERAL É SELETIVIDADE, e ela é derivada do que ELE declarou: **uma folha identifica quando
105
+ * seleciona exatamente UMA regra do corpus dele.** Nenhuma palavra de idioma nenhum participa.
106
+ *
107
+ * MEDIDO nas 25 regras reais de uma população viva:
108
+ *
109
+ * rejeitadas por selecionarem mais de uma regra `size` (5) `body` (3) `default` (2)
110
+ * mantidas por selecionarem exatamente uma `serif` `violet` `grain` `aurora`
111
+ *
112
+ * Mesmo resultado da lista curada, sem uma palavra nossa no núcleo.
113
+ *
114
+ * E O NOME ESCRITO POR INTEIRO NÃO PASSA POR ESSE TESTE: quem escreve `--font-serif` dentro da
115
+ * regra não deixou ambiguidade nenhuma para resolver, e duas regras podem falar do mesmo token de
116
+ * propósito.
117
+ */
118
+ /** Um nome, como uma PALAVRA inteira - `serif` não casa dentro de `serifed`. */
119
+ const mentions = (text, word) => new RegExp(`(^|[^a-z0-9-])${word}([^a-z0-9-]|$)`, "i").test(text);
120
+ /**
121
+ * OS NOMES DECLARADOS QUE ESTE TRECHO REFERENCIA.
122
+ *
123
+ * Três formas, porque um projeto escreve o mesmo token de três jeitos: `var(--font-serif)` no CSS,
124
+ * `--font-serif` cru numa declaração, e `font-serif` como utility - que é o idioma medido em 72%
125
+ * dos `.tsx` dele. Sem a terceira, um repositório Tailwind inteiro não referenciaria nada.
126
+ */
127
+ export function declaredNamesIn(text, declared) {
128
+ const found = new Set();
129
+ for (const name of declared) {
130
+ const bare = name.replace(/^--/, "");
131
+ if (text.includes(name) || mentions(text, bare))
132
+ found.add(name);
133
+ }
134
+ return [...found];
135
+ }
136
+ /** A regra escreve este nome - por inteiro, ou pela folha dele. */
137
+ const writesName = (text, name) => {
138
+ if (text.includes(name) || mentions(text, name.replace(/^--/, "")))
139
+ return "full";
140
+ const leaf = leafOf(name);
141
+ return leaf && mentions(text, leaf) ? "leaf" : null;
142
+ };
143
+ /**
144
+ * AS REGRAS QUE O QUE FOI APAGADO TOCA - e o nome que faz a ligação.
145
+ *
146
+ * O nome sai junto porque a frase que o hook escreve precisa dele: *"você removeu `font-serif`, e
147
+ * esta regra é sobre isso"* é verificável; *"você quebrou uma regra"* é uma acusação sem prova.
148
+ */
149
+ export function rulesTouchedBy(names, rules) {
150
+ if (names.length === 0)
151
+ return [];
152
+ const out = [];
153
+ const taken = new Set();
154
+ for (const name of names) {
155
+ const hits = rules
156
+ .map((rule) => ({ rule, how: writesName(rule.text, name) }))
157
+ .filter((h) => h.how !== null);
158
+ if (hits.length === 0)
159
+ continue;
160
+ /**
161
+ * A FOLHA SÓ VALE QUANDO ELA SELECIONA UMA REGRA SÓ. Uma folha que casa com três regras não
162
+ * está identificando um assunto - ela está aparecendo em prosa, e falar seria ruído.
163
+ * O nome escrito por inteiro não passa por este teste: ali não há ambiguidade a resolver.
164
+ */
165
+ const full = hits.filter((h) => h.how === "full");
166
+ const chosen = full.length > 0 ? full : hits.length === 1 ? hits : [];
167
+ for (const { rule } of chosen) {
168
+ if (taken.has(rule))
169
+ continue;
170
+ taken.add(rule);
171
+ out.push({ rule, through: name });
172
+ }
173
+ }
174
+ return out;
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.418",
3
+ "version": "0.16.419",
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": {