synthesisui 0.16.417 → 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.
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { agentsToMaintain } from "../agents-chosen.js";
4
4
  import { syncClaudeMd } from "../claude-md.js";
5
5
  import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
6
+ import { parseRules } from "../doctrine.js";
6
7
  import { customFontFamilies, googleFontsHref, nextFontSnippet, } from "../fonts.js";
7
8
  import { detectAppDirs, globalSheetOf, prefixFrom } from "../global-sheet.js";
8
9
  import { lockReference } from "../group-role.js";
@@ -375,9 +376,17 @@ export async function add(slug, opts) {
375
376
  * registry working unchanged.
376
377
  */
377
378
  const stack = await detectStack(projectRoot);
379
+ /**
380
+ * AS DUAS ENTRADAS VIRAM A MESMA FORMA ANTES DE TOCAR O DISCO.
381
+ *
382
+ * `ruleSet` vem estruturado e `rules` vem como frase - e enquanto a segunda caía direto no
383
+ * arquivo, o disco dele podia receber uma lista mista sem nada reclamar. `parseRules` normaliza
384
+ * as duas para `{ text, applies }`, e desfaz o prefixo `ds-a + ds-b:` da forma antiga no
385
+ * caminho, então um registry que ainda manda frase não perde o alcance da regra.
386
+ */
378
387
  const rules = payload.ruleSet && payload.ruleSet.length > 0
379
388
  ? rulesForProject(payload.ruleSet, stack)
380
- : (payload.rules ?? []);
389
+ : parseRules(payload.rules ?? []);
381
390
  const leftOut = payload.ruleSet
382
391
  ? describeFiltered(payload.ruleSet, stack)
383
392
  : null;
@@ -16,6 +16,7 @@ import { diagnose, nameToWrite, scanSource, siblingTokens, } from "../doctor/sca
16
16
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
17
17
  import { withTheirNames } from "../doctor/their-names.js";
18
18
  import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
19
+ import { parseRules } from "../doctrine.js";
19
20
  /**
20
21
  * `detectAppDirs` SAIU DESTE IMPORT em 09/09: quem pergunta "quais pastas são app?" agora é
21
22
  * `readWiringPerApp`, que responde a fiação de cada uma. O comando não escolhe mais um app.
@@ -320,13 +321,23 @@ measured) {
320
321
  if (doctrineRaw) {
321
322
  try {
322
323
  const parsed = JSON.parse(doctrineRaw);
323
- if (Array.isArray(parsed.rules))
324
+ /**
325
+ * AS DUAS FORMAS DO DISCO, lidas pela mesma porta (`parseRules`).
326
+ *
327
+ * Este filtro era `typeof r === "string"`, e com a regra passando a viajar como dado ele
328
+ * DESCARTARIA calado toda regra de uma instalação nova - o pior resultado possível para
329
+ * uma doutrina, que é sumir sem ninguém saber. E a leitura pela porta única também
330
+ * devolve o alcance às instalações ANTIGAS: o prefixo `ds-a + ds-b:` é desfeito ali, sem
331
+ * reimportar nada.
332
+ */
333
+ const parsedRules = parseRules(parsed.rules);
334
+ if (parsedRules.length > 0)
324
335
  doctrines.push({
325
336
  ...(typeof parsed.version === "number"
326
337
  ? { version: parsed.version }
327
338
  : {}),
328
339
  ...(typeof parsed.name === "string" ? { name: parsed.name } : {}),
329
- rules: parsed.rules.filter((r) => typeof r === "string"),
340
+ rules: parsedRules,
330
341
  });
331
342
  }
332
343
  catch {
@@ -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
  }
@@ -12,6 +12,7 @@ import { readEvents } from "../doctor/ledger.js";
12
12
  import { fileRequest } from "../doctor/requests.js";
13
13
  import { diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
14
14
  import { nearestToken, normalizeValue, tokenFor } from "../doctor/tokens.js";
15
+ import { ruleLine } from "../doctrine.js";
15
16
  import { fromCensus } from "../memory/observation.js";
16
17
  import { handleRecall, handleRemember, MEMORY_TOOLS } from "../memory/tools.js";
17
18
  import { readInstalledConvention } from "../project-facts.js";
@@ -523,7 +524,13 @@ async function systemDoctrine(root) {
523
524
  rules.length > 0
524
525
  ? `# Rules - maximum authority, ${rules.length} of them${pinned ? `, as of v${pinned}` : ""}`
525
526
  : "",
526
- ...rules.map((r) => `- ${r}`),
527
+ /**
528
+ * `ruleLine` É O QUE MANTÉM ESTA SAÍDA IDÊNTICA. A regra passou a viajar como dado
529
+ * (`{ text, applies }`), e interpolar o objeto aqui imprimiria `[object Object]` sem o `tsc`
530
+ * reclamar - um template literal aceita qualquer coisa. O alcance volta a ser prefixo na hora
531
+ * de MOSTRAR, que é onde ele sempre esteve para quem lê.
532
+ */
533
+ ...rules.map((r) => `- ${ruleLine(r)}`),
527
534
  ].filter(Boolean);
528
535
  const pulse = await sessionPulse(root, pinned);
529
536
  return [
@@ -1706,7 +1713,7 @@ cli) {
1706
1713
  const plan = composePlan({
1707
1714
  documents,
1708
1715
  table,
1709
- rules: doctrines.flatMap((d) => d.rules),
1716
+ rules: doctrines.flatMap((d) => d.rules).map(ruleLine),
1710
1717
  /**
1711
1718
  * A regra tem a FRASE e o PACOTE em campos separados (`text`,
1712
1719
  * `requires`) - mandar a frase sozinha esconderia o que instalar, e
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A REGRA DO SISTEMA DELE, COMO DADO - e não como frase.
3
+ *
4
+ * ═══ O QUE ABRIU ESTE ARQUIVO, medido em 12/09 ═══
5
+ *
6
+ * O agente dele apagou a serif itálica de seis lugares da landing do `codelevel`, e nada avisou.
7
+ * A regra existia, instalada, em disco, no `doctrine.json` daquele projeto - a décima de vinte e
8
+ * cinco, verbatim:
9
+ *
10
+ * "A display headline breaks onto a line set in the serif, italic - that line carries the
11
+ * emphasis of the sentence, never the whole headline."
12
+ *
13
+ * O conhecimento estava a dois diretórios de distância da edição e ninguém o entregou. Palavras
14
+ * dele: *"o sistema fala antes do dano"*.
15
+ *
16
+ * ═══ A CAUSA, e ela é de uma linha ═══
17
+ *
18
+ * Do NOSSO lado a regra é estruturada: `applies` diz quais componentes ela governa, e
19
+ * `rules-for-component.ts` casa por esse campo há semanas. No caminho até o disco dele,
20
+ * `rulesForProject` achatava a estrutura num PREFIXO DE PROSA - `ds-button + ds-icon-button: <o
21
+ * texto>` - e a partir dali nada mais podia perguntar *"esta regra vale para o arquivo que está
22
+ * sendo escrito?"*. A resposta existia e era jogada fora na exportação.
23
+ *
24
+ * ═══ POR QUE UM MÓDULO, e não um `JSON.parse` em cada leitor ═══
25
+ *
26
+ * O `doctrine.json` é lido em três lugares - o doctor, o `system_doctrine` do MCP e o compositor
27
+ * de plano -, e a partir de agora existem DUAS formas no disco do mundo: a nova, estruturada, e a
28
+ * antiga, em string, que está instalada em todo projeto que rodou `connect` antes de hoje. Três
29
+ * leitores decidindo sozinhos o que fazer com duas formas são três chances de discordar sobre a
30
+ * mesma regra.
31
+ *
32
+ * ═══ E A FORMA ANTIGA NÃO PERDE O ALCANCE ═══
33
+ *
34
+ * O prefixo era um achatamento REVERSÍVEL: `ds-card + ds-noise-overlay: <texto>` carrega os dois
35
+ * nomes, só que em prosa. `parseRule` desfaz isso na leitura, então uma instalação antiga ganha o
36
+ * alcance de volta sem reimportar nada - que é a diferença entre uma correção que alcança quem já
37
+ * é cliente e uma que só alcança quem instalar amanhã.
38
+ */
39
+ /**
40
+ * O PREFIXO QUE A FORMA ANTIGA USA: `ds-card + ds-noise-overlay: `.
41
+ *
42
+ * Ancorado no começo e exigindo o `ds-` em cada nome, porque uma frase pode conter dois pontos -
43
+ * *"Dark is a class, never the OS preference: @custom-variant dark"* é a segunda regra real do
44
+ * sistema dele, e sem a âncora ela viraria um componente chamado `Dark is a class, never the OS
45
+ * preference`.
46
+ */
47
+ const LEGACY_SCOPE = /^((?:ds-[a-z0-9-]+)(?:\s*\+\s*ds-[a-z0-9-]+)*)\s*:\s*/;
48
+ /** Uma regra vinda do disco, em qualquer das duas formas - ou `null` quando não há frase. */
49
+ export function parseRule(raw) {
50
+ if (typeof raw === "string") {
51
+ const text = raw.trim();
52
+ if (!text)
53
+ return null;
54
+ const m = LEGACY_SCOPE.exec(text);
55
+ if (!m)
56
+ return { text, applies: [] };
57
+ return {
58
+ text: text.slice(m[0].length).trim(),
59
+ applies: m[1].split("+").map((n) => n.trim().replace(/^ds-/, "")),
60
+ };
61
+ }
62
+ if (!raw || typeof raw !== "object")
63
+ return null;
64
+ const r = raw;
65
+ const text = typeof r.text === "string" ? r.text.trim() : "";
66
+ if (!text)
67
+ return null;
68
+ const applies = Array.isArray(r.applies)
69
+ ? r.applies.filter((a) => typeof a === "string" && !!a.trim())
70
+ : [];
71
+ return { text, applies: applies.map((a) => a.trim().replace(/^ds-/, "")) };
72
+ }
73
+ /** Todas as regras de um `doctrine.json`, nas duas formas, sem as ilegíveis. */
74
+ export function parseRules(raw) {
75
+ if (!Array.isArray(raw))
76
+ return [];
77
+ const out = [];
78
+ for (const entry of raw) {
79
+ const rule = parseRule(entry);
80
+ if (rule)
81
+ out.push(rule);
82
+ }
83
+ return out;
84
+ }
85
+ /**
86
+ * A REGRA COMO ALGUÉM A LÊ - a mesma frase que o disco carregava antes.
87
+ *
88
+ * O alcance volta a ser prefixo AQUI, na hora de mostrar, e não no dado. Quem imprime continua
89
+ * imprimindo o que sempre imprimiu; quem PERGUNTA passa a ter o campo.
90
+ */
91
+ export function ruleLine(rule) {
92
+ const scope = rule.applies.length > 0
93
+ ? `${rule.applies.map((a) => `ds-${a}`).join(" + ")}: `
94
+ : "";
95
+ return `${scope}${rule.text}`;
96
+ }
@@ -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
  *
@@ -1,18 +1,3 @@
1
- /**
2
- * WHICH RULES BELONG IN *THIS* PROJECT.
3
- *
4
- * A rule travels with the design system, and the system gets installed places it
5
- * was never written for. "The state arrives through this hook" is true about Next
6
- * and actively wrong about Vue - so a rule carries the environments it holds in,
7
- * and the filtering happens HERE, because the consumer's stack is the one thing
8
- * the platform cannot know (dono, 01/08).
9
- *
10
- * THE DEFAULT WHEN WE CANNOT TELL IS TO KEEP IT. A rule whose condition does not
11
- * match is removed, because obeying it would be a mistake. A rule we cannot
12
- * evaluate - the stack came back empty, or it names something we do not detect -
13
- * stays, because a rule nobody can judge is safer present than silently gone.
14
- * Those are two different situations and they get two different answers.
15
- */
16
1
  const norm = (v) => v.trim().toLowerCase();
17
2
  /**
18
3
  * True when this rule holds in a project built with `stack`.
@@ -45,10 +30,18 @@ export function rulesForProject(rules, stack) {
45
30
  const text = rule.text?.trim();
46
31
  if (!text)
47
32
  continue;
48
- const scope = rule.applies?.length > 0
49
- ? `${rule.applies.map((a) => `ds-${a}`).join(" + ")}: `
50
- : "";
51
- out.push(`${scope}${text}`);
33
+ /**
34
+ * O ALCANCE SAI DAQUI COMO CAMPO, e não mais como prefixo de prosa.
35
+ *
36
+ * Esta função achatava `applies` em `ds-a + ds-b: <texto>` e era o último ponto onde a
37
+ * estrutura existia: do disco em diante, nada mais podia perguntar se a regra vale para o
38
+ * arquivo que está sendo escrito. Foi assim que a regra da serif do `codelevel` - a décima
39
+ * de vinte e cinco, instalada, correta - não disse nada quando um agente a apagou de seis
40
+ * lugares (12/09).
41
+ *
42
+ * A frase não mudou: `ruleLine` volta a montar o prefixo na hora de MOSTRAR.
43
+ */
44
+ out.push({ text, applies: (rule.applies ?? []).map((a) => a.trim()) });
52
45
  }
53
46
  return out;
54
47
  }
@@ -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.417",
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": {