synthesisui 0.16.165 → 0.16.167

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.
@@ -21,6 +21,7 @@ import { describeConvention, describeRemainder, detectConventions, } from "../do
21
21
  import { pageLaws } from "../doctor/page-laws.js";
22
22
  import { callSiteNode, placeLayers, } from "../doctor/place-layers.js";
23
23
  import { publicApi, requiredProps } from "../doctor/public-api.js";
24
+ import { aliasesOf, describeReachability, edgesIn, reachabilityOf, } from "../doctor/reachability.js";
24
25
  import { diagnose, scanSource } from "../doctor/scan.js";
25
26
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
26
27
  import { describeSignals, emptySignals, finishSignals, readSignalsInto, } from "../doctor/signals.js";
@@ -973,12 +974,23 @@ export async function takeCensus(root, opts) {
973
974
  .then((raw) => JSON.parse(raw).name ?? null)
974
975
  .catch(() => null);
975
976
  const shared = scopePkg ? [...internal, scopePkg] : internal;
977
+ /**
978
+ * O GRAFO DE IMPORTS DA EVIDÊNCIA, montado no laço que já abre cada arquivo.
979
+ *
980
+ * O `--usage` doa CONTAGEM e LEI, então código que nenhuma rota atinge vota nas leis do cliente -
981
+ * 180 de 966 arquivos no repo do dono (07/08). Refazer a varredura para montar o grafo seria
982
+ * percorrer o repo inteiro de novo por um dado que este laço já tem na mão. Ver `reachability.ts`.
983
+ */
984
+ const edges = new Map();
985
+ const aliases = [];
976
986
  for (const u of opts?.usage ?? []) {
977
987
  const uInternal = await internalSpecifiers(u.path);
988
+ aliases.push(...(await aliasesOf(u.path)));
978
989
  for await (const file of walk(u.path)) {
979
990
  const src = await readFile(file, "utf8").catch(() => "");
980
991
  if (!src)
981
992
  continue;
993
+ edges.set(file, edgesIn(src));
982
994
  const rel = join(u.label, relative(u.path, file));
983
995
  if (/(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/.test(rel)) {
984
996
  continue;
@@ -1215,6 +1227,11 @@ export async function takeCensus(root, opts) {
1215
1227
  *
1216
1228
  * `componentFiles` segue existindo para as formas de estilo, que SÃO por arquivo.
1217
1229
  */
1230
+ /**
1231
+ * O GRAFO SÓ FECHA DEPOIS QUE TODOS OS ARQUIVOS FORAM VISTOS: um especificador só resolve para um
1232
+ * arquivo que já esteja na lista, então isto não pode rodar dentro do laço que a preenche.
1233
+ */
1234
+ const reachability = edges.size > 0 ? await reachabilityOf({ edges, aliases }) : null;
1218
1235
  const coverage = summarizeCoverage(shapes, defined.length || componentFiles, Object.keys(looks).length, Object.values(looks).reduce((n, look) => n +
1219
1236
  Object.keys(look.base).length +
1220
1237
  Object.keys(look.dark ?? {}).length +
@@ -1350,6 +1367,25 @@ export async function takeCensus(root, opts) {
1350
1367
  for (const line of coverageLines)
1351
1368
  console.log(body(line));
1352
1369
  }
1370
+ /**
1371
+ * O QUE VOTA NAS LEIS DELE SEM ESTAR VIVO - dito, e nada excluído.
1372
+ *
1373
+ * Lei 7 do `CLAUDE.md`: o que a esteira não sabe tratar se DECLARA, com o número do repo dele, no
1374
+ * momento do censo. Silêncio sobre uma lacuna é o que faz um produto correto parecer quebrado - e
1375
+ * aqui o silêncio custava mais que aparência, porque as leis saíam distorcidas sem ninguém saber.
1376
+ */
1377
+ const reachLines = reachability ? describeReachability(reachability) : [];
1378
+ if (reachLines.length > 0) {
1379
+ console.log("");
1380
+ console.log(section("What no route reaches"));
1381
+ for (const line of reachLines)
1382
+ console.log(body(line));
1383
+ if (reachability && reachability.unreached.length > 0)
1384
+ console.log(body(paint.faint(`${reachability.unreached
1385
+ .slice(0, 4)
1386
+ .map((f) => relative(root, f))
1387
+ .join(", ")}${reachability.unreached.length > 4 ? `, and ${reachability.unreached.length - 4} more` : ""} - the full list travels in the census, and nothing was left out because of it`)));
1388
+ }
1353
1389
  /**
1354
1390
  * WHAT THE GATE TURNED AWAY - said out loud, grouped, with the reason.
1355
1391
  *
@@ -1622,6 +1658,17 @@ export async function takeCensus(root, opts) {
1622
1658
  * ninguém ter que reportar (dono, 04/08).
1623
1659
  */
1624
1660
  ledger,
1661
+ /**
1662
+ * O QUE NENHUMA ROTA ATINGE, entre os arquivos que doam evidência.
1663
+ *
1664
+ * O `--usage` não doa token nem componente - doa CONTAGEM e LEI, que é o que transforma uma
1665
+ * escolha assentada numa lei que governa. Então código morto ali VOTA nas leis do cliente, e
1666
+ * até 07/08 nada contava: 180 de 966 arquivos no repo do dono.
1667
+ *
1668
+ * Viaja com o censo e NÃO exclui nada. Ver `reachability.ts` para o motivo de ser um grafo e não
1669
+ * uma regra de nome - `_legacy` tinha 178 arquivos e 106 deles estavam vivos.
1670
+ */
1671
+ ...(reachability ? { reachability } : {}),
1625
1672
  /**
1626
1673
  * A ESCALA QUE O `sx` DELES USA, só quando eles declaram uma diferente do default do MUI.
1627
1674
  * Medida no censo e consumida pela derivação - ver `readSxProps`.
@@ -0,0 +1,293 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ /**
4
+ * OS NOMES QUE O ROTEADOR MONTA SOZINHO.
5
+ *
6
+ * Um arquivo com um destes nomes é uma porta de entrada mesmo que ninguém o importe - é o framework
7
+ * que o monta. Sem esta lista, um app inteiro sairia como inalcançável a partir do nada.
8
+ */
9
+ const ENTRY = /\/(page|layout|route|template|default|loading|error|not-found|global-error|middleware|instrumentation)\.[jt]sx?$/;
10
+ const EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
11
+ /**
12
+ * Os especificadores que um arquivo importa - estáticos, re-exports e dinâmicos.
13
+ *
14
+ * Re-export conta: um barril (`export { X } from "./x"`) é o caminho pelo qual metade dos projetos
15
+ * chega nos próprios componentes, e ignorá-lo transformaria uma pasta inteira em morta.
16
+ */
17
+ export function edgesIn(source) {
18
+ const out = [];
19
+ for (const re of [
20
+ /\bfrom\s+["']([^"']+)["']/g,
21
+ /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
22
+ /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
23
+ /\bimport\s+["']([^"']+)["']/g,
24
+ ])
25
+ for (const m of source.matchAll(re))
26
+ out.push(m[1]);
27
+ return out;
28
+ }
29
+ /**
30
+ * Os apelidos que o projeto declara - `paths` do tsconfig, que é onde `@/` mora.
31
+ *
32
+ * Sem eles, um projeto que importa tudo por `@/components/...` lê como um grafo sem aresta nenhuma,
33
+ * e a medição diria que o app inteiro está morto. Ler o `tsconfig` é a diferença entre uma medição e
34
+ * um alarme.
35
+ */
36
+ /**
37
+ * Tira comentários de um tsconfig SEM ATRAVESSAR STRING - e essa ressalva é o ponto inteiro.
38
+ *
39
+ * A primeira versão era um `replace` de duas regex, e ela comia o próprio alias que veio buscar:
40
+ * `"@/*"` - o apelido padrão de qualquer projeto Next - começa com `/*`, que lê como abertura de
41
+ * bloco. O parser engolia dali até o primeiro `*` `/` adiante, o JSON quebrava, e a função devolvia
42
+ * zero alias. Consequência: todo `@/…` deixava de resolver e o app inteiro saía como código morto
43
+ * (07/08, 1573 arestas perdidas no app do dono).
44
+ *
45
+ * Um erro de leitura NOSSO virando uma acusação sobre o código de alguém é o pior modo de falha que
46
+ * esta esteira tem, e é por isso que o scanner sabe onde uma string começa e acaba.
47
+ */
48
+ function withoutComments(raw) {
49
+ let out = "";
50
+ let inString = false;
51
+ for (let i = 0; i < raw.length; i += 1) {
52
+ const c = raw[i];
53
+ const next = raw[i + 1];
54
+ if (inString) {
55
+ out += c;
56
+ if (c === "\\") {
57
+ out += next ?? "";
58
+ i += 1;
59
+ }
60
+ else if (c === '"')
61
+ inString = false;
62
+ continue;
63
+ }
64
+ if (c === '"') {
65
+ inString = true;
66
+ out += c;
67
+ continue;
68
+ }
69
+ if (c === "/" && next === "/") {
70
+ while (i < raw.length && raw[i] !== "\n")
71
+ i += 1;
72
+ out += "\n";
73
+ continue;
74
+ }
75
+ if (c === "/" && next === "*") {
76
+ i += 2;
77
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
78
+ i += 1;
79
+ i += 1;
80
+ continue;
81
+ }
82
+ out += c;
83
+ }
84
+ return out;
85
+ }
86
+ export async function aliasesOf(root) {
87
+ return readAliases(join(root, "tsconfig.json"), 0);
88
+ }
89
+ /**
90
+ * Lê um tsconfig POR CAMINHO DE ARQUIVO, e é isso que faz o `extends` funcionar.
91
+ *
92
+ * A primeira versão pegava o diretório do arquivo apontado e voltava a procurar `tsconfig.json` nele
93
+ * - então um `extends: "../../tsconfig.base.json"` lia o `tsconfig.json` da raiz, que não existe, e
94
+ * devolvia zero alias. No app do dono isso fazia todo `@/…` deixar de resolver: 1053 arestas
95
+ * perdidas, e o app inteiro lendo como morto (07/08).
96
+ */
97
+ async function readAliases(path,
98
+ /** Guarda contra `extends` circular, e contra uma cadeia que ninguém pretendia seguir. */
99
+ depth) {
100
+ if (depth > 4)
101
+ return [];
102
+ const raw = await readFile(path, "utf8").catch(() => "");
103
+ if (!raw)
104
+ return [];
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(withoutComments(raw));
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ const here = dirname(path);
113
+ const inherited = parsed.extends?.startsWith(".")
114
+ ? await readAliases(resolve(here, parsed.extends), depth + 1)
115
+ : [];
116
+ /**
117
+ * `paths` é relativo ao `baseUrl` DO ARQUIVO QUE O DECLARA - um base config na raiz aponta para as
118
+ * pastas a partir da raiz, e resolvê-lo a partir do app daria um caminho que não existe.
119
+ */
120
+ const base = resolve(here, parsed.compilerOptions?.baseUrl ?? ".");
121
+ const own = Object.entries(parsed.compilerOptions?.paths ?? {}).map(([from, to]) => ({
122
+ prefix: from.replace(/\*$/, ""),
123
+ to: to.map((t) => resolve(base, t.replace(/\*$/, ""))),
124
+ }));
125
+ /** O do próprio projeto vence o herdado, que é a regra do TypeScript. */
126
+ return [...own, ...inherited];
127
+ }
128
+ /**
129
+ * Parece um caminho DESTE repo, e não um pacote?
130
+ *
131
+ * `@/x`, `~/x` e `#x` são as três formas de apelido local que aparecem na prática, e nenhuma é um
132
+ * pacote npm válido - um escopo real é `@escopo/nome`. Existe para o contador de `unresolved` pegar
133
+ * o caso em que a configuração de alias falhou: sem isto, um `@/` sem alias casado era descartado em
134
+ * silêncio, e o número de mortos subia sem a ressalva que devia acompanhá-lo.
135
+ */
136
+ export function looksLocal(spec) {
137
+ return /^(\.|~\/|#)/.test(spec) || /^@\//.test(spec);
138
+ }
139
+ /**
140
+ * Um import de ASSET não é uma aresta de código.
141
+ *
142
+ * `import "./globals.css"` e `import logo from "./logo.png"` são reais e não fazem componente nenhum
143
+ * ficar vivo. Contá-los como aresta perdida inflava a ressalva que qualifica todo o resto - e uma
144
+ * ressalva inflada é tão inútil quanto uma ausente.
145
+ */
146
+ const ASSET = /\.(css|scss|sass|less|styl|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|mp4|webm|json|md|txt|ya?ml|graphql|wasm)$/i;
147
+ /**
148
+ * Existe como arquivo? Com memória, porque a mesma resposta é pedida centenas de vezes.
149
+ *
150
+ * Cada especificador tenta até treze candidatos (o base, seis extensões, seis `index`), e um barril
151
+ * importado por cem arquivos repete os mesmos treves `stat` cem vezes. Sem isto, medir um app de 1446
152
+ * arquivos passava de três segundos - e um leitor que roda dentro de todo import não pode pagar isso
153
+ * por um dado que não muda durante a rodada.
154
+ */
155
+ function fileChecker() {
156
+ const known = new Map();
157
+ return (path) => {
158
+ const seen = known.get(path);
159
+ if (seen)
160
+ return seen;
161
+ const asked = stat(path).then((s) => s.isFile(), () => false);
162
+ known.set(path, asked);
163
+ return asked;
164
+ };
165
+ }
166
+ /** Um especificador vira um arquivo real, ou nada. `null` é contado, nunca engolido. */
167
+ async function toFile(known, from, spec, aliases, isFile) {
168
+ const bases = [];
169
+ if (spec.startsWith("."))
170
+ bases.push(resolve(dirname(from), spec));
171
+ else
172
+ for (const a of aliases)
173
+ if (spec.startsWith(a.prefix))
174
+ for (const t of a.to)
175
+ bases.push(join(t, spec.slice(a.prefix.length)));
176
+ if (bases.length === 0)
177
+ return null;
178
+ for (const base of bases)
179
+ for (const candidate of [
180
+ base,
181
+ ...EXTS.map((e) => base + e),
182
+ ...EXTS.map((e) => join(base, `index${e}`)),
183
+ ]) {
184
+ if (known.has(candidate))
185
+ return candidate;
186
+ /**
187
+ * Fora da lista varrida ainda pode existir: um alias que aponta para outro pacote do monorepo,
188
+ * ou um `.js` que o walk deste escopo não coletou. O próprio `base` entra no teste - um alias
189
+ * que aponta DIRETO para um arquivo (`"@x/core": ["packages/core/src/index.ts"]`) não tem
190
+ * sufixo nenhum, e pular o base fazia esse caso nunca resolver.
191
+ */
192
+ if (await isFile(candidate))
193
+ return candidate;
194
+ }
195
+ return null;
196
+ }
197
+ /**
198
+ * Quem o roteador alcança, e quem não.
199
+ *
200
+ * `edges` chega já coletado pelo laço que já abriu cada arquivo - refazer a leitura para montar o
201
+ * grafo seria uma segunda varredura do repo inteiro por um dado que a primeira já tinha na mão.
202
+ */
203
+ export async function reachabilityOf(input) {
204
+ /**
205
+ * O UNIVERSO É CÓDIGO-FONTE, e não tudo que tem extensão.
206
+ *
207
+ * `.d.ts` são declarações de tipo e `next.config.js`/`tailwind.config.ts` são configuração: nenhum
208
+ * é montado por uma rota e nenhum doa evidência sobre como o produto usa o sistema. Contá-los
209
+ * inflava o denominador com arquivos que são inalcançáveis por definição - 2090 no lugar de 966 no
210
+ * app do dono (07/08), e um número inflado no denominador é uma acusação inflada no numerador.
211
+ */
212
+ const isFile = fileChecker();
213
+ const known = new Set([...input.edges.keys()].filter((f) => /\.[jt]sx?$/.test(f) &&
214
+ !/\.d\.ts$/.test(f) &&
215
+ !/\/[a-z.-]+\.config\.[jt]sx?$/.test(f)));
216
+ const graph = new Map();
217
+ let unresolved = 0;
218
+ for (const [file, specs] of input.edges) {
219
+ if (!known.has(file))
220
+ continue;
221
+ const out = [];
222
+ for (const spec of specs) {
223
+ const mine = spec.startsWith(".") ||
224
+ input.aliases.some((a) => spec.startsWith(a.prefix));
225
+ /**
226
+ * Pacote de terceiro não é aresta deste repo. Mas um apelido LOCAL que nenhum alias casa é uma
227
+ * aresta PERDIDA - e era descartado aqui em silêncio, o que subia o número de mortos sem a
228
+ * ressalva que devia vir junto (07/08).
229
+ */
230
+ if (!mine) {
231
+ if (looksLocal(spec) && !ASSET.test(spec))
232
+ unresolved += 1;
233
+ continue;
234
+ }
235
+ const hit = await toFile(known, file, spec, input.aliases, isFile);
236
+ if (hit)
237
+ out.push(hit);
238
+ /**
239
+ * Asset que não resolve não é aresta perdida - ver `ASSET`. A tentativa acontece assim mesmo,
240
+ * porque um `./styles.css` que EXISTE é um nó legítimo do grafo, e recusá-lo antes de tentar
241
+ * derrubou 300 arquivos alcançados na primeira versão desta linha (07/08).
242
+ */ else if (!ASSET.test(spec))
243
+ unresolved += 1;
244
+ }
245
+ graph.set(file, out);
246
+ }
247
+ const entries = [...known].filter((f) => ENTRY.test(f));
248
+ const seen = new Set();
249
+ const queue = [...entries];
250
+ while (queue.length > 0) {
251
+ const file = queue.pop();
252
+ if (!file || seen.has(file))
253
+ continue;
254
+ seen.add(file);
255
+ for (const next of graph.get(file) ?? [])
256
+ if (!seen.has(next))
257
+ queue.push(next);
258
+ }
259
+ return {
260
+ entries: entries.length,
261
+ files: known.size,
262
+ reached: seen.size,
263
+ unreached: [...known].filter((f) => !seen.has(f)).sort(),
264
+ unresolved,
265
+ };
266
+ }
267
+ /**
268
+ * A frase, e ela QUALIFICA o número antes de o dar.
269
+ *
270
+ * Um "19% do que vota nas suas leis é código morto" sem a ressalva do `unresolved` é uma afirmação
271
+ * que quem lê não tem como checar - e a primeira coisa que alguém faz com um número desses é apagar
272
+ * arquivo. A ordem aqui é deliberada: o que foi contado, depois o que a contagem não soube ver.
273
+ */
274
+ export function describeReachability(r) {
275
+ if (r.files === 0 || r.entries === 0)
276
+ return [];
277
+ const dead = r.unreached.length;
278
+ if (dead === 0)
279
+ return [
280
+ `Every one of the ${r.files} files behind your evidence is reached from one of its ${r.entries} entry points - nothing dead is voting on your laws.`,
281
+ ];
282
+ const pct = Math.round((dead / r.files) * 100);
283
+ const out = [
284
+ `${dead} of ${r.files} files (${pct}%) are not reached from any of the ${r.entries} entry points this app declares - they are counted as evidence, which means dead code is voting on which of your choices became laws.`,
285
+ ];
286
+ /**
287
+ * A RESSALVA VIAJA COM O NÚMERO, sempre - e o corte é 5% dos especificadores. Acima disso o grafo
288
+ * perdeu arestas demais para a palavra "morto" significar alguma coisa.
289
+ */
290
+ if (r.unresolved > 0)
291
+ out.push(`${r.unresolved} import${r.unresolved === 1 ? "" : "s"} could not be resolved to a file, so that count is a CEILING and not a measurement: an import this reader cannot follow turns living code into "dead". Nothing was excluded on account of it.`);
292
+ return out;
293
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.165",
3
+ "version": "0.16.167",
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": {