@joao.dadas/lume 0.0.0-stage → 0.4.0

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.
package/src/check.mjs ADDED
@@ -0,0 +1,593 @@
1
+ // lume check: confere, sem julgar, se o projeto segue o lume. Cada violação
2
+ // tem regra, arquivo e linha. O mesmo comando roda no fim do init e no CI.
3
+
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { adotouLume, LAYOUTS, lerProjeto, listar } from "./projeto.mjs";
7
+ import { criarRegistry, emLotes } from "./registry.mjs";
8
+ import {
9
+ linhaDe,
10
+ normalizar,
11
+ semComentarios,
12
+ semComentariosCss,
13
+ } from "./texto.mjs";
14
+
15
+ export const REGRAS = {
16
+ "config/lume-json":
17
+ "Rode `npx -y @joao.dadas/lume@latest init`: ele grava o lume.json com a versão e o layout.",
18
+ "config/components-json":
19
+ "O components.json precisa ser o do lume (style base-vega, iconLibrary tabler, tsx). Apague o atual e rode o init do base.",
20
+ "config/alias":
21
+ "Os aliases do components.json desviam os componentes para outra pasta. O padrão é ui em @/components/ui, components em @/components e utils em @/lib/utils: o lume mora em components/ui, sem pasta paralela.",
22
+ "css/base": "O CSS global não tem a base do lume. Rode o init do base.",
23
+ "css/fonte":
24
+ "A fonte é a da base (Inter Variable e Geist Mono pelo Fontsource), sem font-feature-settings, sem outro pacote de fonte e sem redefinir --font-sans.",
25
+ "deps/proibida":
26
+ "Tire a dependência: ícone é Tabler, primitivo é Base UI, os dois pelo lume.",
27
+ "ui/editado":
28
+ "Arquivo da registry editado à mão. Reinstale o item com --overwrite e leve a variação para className na composição.",
29
+ "ui/fora-da-registry":
30
+ "Componente que não é do lume dentro de components/ui. Troque os usos pelo componente do lume e apague o arquivo; composição do produto mora em components/app.",
31
+ "ui/paralelo":
32
+ "Pasta com cópias de primitivos fora de components/ui. Troque os imports para components/ui e apague a pasta.",
33
+ "codigo/import":
34
+ "Import de biblioteca que o lume substitui: ícone é @tabler/icons-react, primitivo é o de components/ui, fonte vem do CSS.",
35
+ "codigo/aschild": 'asChild é do Radix. No lume: render={<Link href="/x" />}.',
36
+ "codigo/data-state":
37
+ "data-[state=...] é do Radix. No lume: data-open, data-closed, data-active.",
38
+ "codigo/peso": "O peso máximo no app é font-medium.",
39
+ "codigo/titulo":
40
+ "Título de página é text-base. text-2xl ou maior não existe no app.",
41
+ "codigo/cinza":
42
+ "Cinza cru é neutral ou token. slate, gray, zinc e stone não entram.",
43
+ "codigo/sombra":
44
+ "Sombra do Tailwind não entra: a superfície usa shadow-(--custom-shadow).",
45
+ "codigo/espaco": "space-x e space-y não entram: empilhe com flex e gap.",
46
+ };
47
+
48
+ // Pacotes que o lume substitui. A mesma lista barra a dependência no
49
+ // package.json e o import no código; o import pega também os subcaminhos.
50
+ const PACOTES_PROIBIDOS = [
51
+ "lucide-react",
52
+ "radix-ui",
53
+ "@radix-ui/*",
54
+ "react-icons",
55
+ "@heroicons/*",
56
+ "@mui/*",
57
+ "@mantine/*",
58
+ "@headlessui/*",
59
+ "@chakra-ui/*",
60
+ "antd",
61
+ "inter-ui",
62
+ ];
63
+
64
+ const escapar = (t) => t.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
65
+
66
+ const DEPS_PROIBIDAS = PACOTES_PROIBIDOS.map((p) =>
67
+ p.endsWith("/*")
68
+ ? new RegExp(`^${escapar(p.slice(0, -1))}`)
69
+ : new RegExp(`^${escapar(p)}$`),
70
+ );
71
+
72
+ const ALTERNATIVAS_IMPORT = [
73
+ ...PACOTES_PROIBIDOS.map((p) =>
74
+ p.endsWith("/*")
75
+ ? `${escapar(p.slice(0, -1))}[^"']+`
76
+ : `${escapar(p)}(?:/[^"']*)?`,
77
+ ),
78
+ "next\\/font\\/(?:google|local)",
79
+ ];
80
+
81
+ const IMPORTS_PROIBIDOS = new RegExp(
82
+ `(?:from\\s+|import\\s*\\(\\s*|import\\s+)["'](${ALTERNATIVAS_IMPORT.join("|")})["']`,
83
+ "g",
84
+ );
85
+
86
+ const PADROES_CODIGO = [
87
+ ["codigo/aschild", /\basChild\b/g],
88
+ [
89
+ "codigo/data-state",
90
+ /data-\[state=(?!["']?selected["']?\])["']?[a-z-]+["']?\]/g,
91
+ ],
92
+ ["codigo/peso", /(?<![\w-])font-(?:bold|semibold|extrabold|black)(?![\w-])/g],
93
+ ["codigo/titulo", /(?<![\w-])text-(?:[2-9]xl)(?![\w-])/g],
94
+ [
95
+ "codigo/cinza",
96
+ /(?<![\w-])(?:[a-z-]+-)?(?:slate|gray|zinc|stone)-(?:50|[1-9]00|950)(?![\w-])/g,
97
+ ],
98
+ // shadow sozinho e shadow-xs a shadow-2xl; shadow-(--custom-shadow) e
99
+ // shadow-[...] passam, e o nome solto de variável (shadow =, { shadow }) não conta.
100
+ [
101
+ "codigo/sombra",
102
+ /(?<![\w.-])shadow(?:-(?:xs|sm|md|lg|xl|2xl))?(?![\w\-/([])(?!\s*[=:,;.)}?])/g,
103
+ ],
104
+ ["codigo/espaco", /(?<![\w-])-?space-[xy]-[\w.[\]]+/g],
105
+ ];
106
+
107
+ const EXCECAO = /lume:excecao\s+motivo=\S/;
108
+ const CODIGO = /\.(tsx?|jsx?|mjs|cjs)$/;
109
+ const NAO_ESCANEAR =
110
+ /(\.spec\.|\.test\.|\.stories\.|\.config\.|(^|\/)(e2e|__tests__|__mocks__|__fixtures__)\/)/;
111
+
112
+ /**
113
+ * O que é código do app, o mesmo para código e CSS: a pasta do `@/*` do
114
+ * tsconfig; com o app na raiz, as pastas de sempre.
115
+ */
116
+ function escopoDoApp(projeto) {
117
+ if (projeto.prefixo) return (rel) => rel.startsWith(projeto.prefixo);
118
+ return (rel) =>
119
+ /^(app|components|lib|hooks|pages|features|styles)\//.test(rel);
120
+ }
121
+
122
+ /** Caminho de instalação de um arquivo da registry, já no layout do projeto. */
123
+ function caminhoInstalado(arquivo, tipo, projeto) {
124
+ const nome = path.posix.basename(arquivo.path);
125
+ if (arquivo.target) {
126
+ if (arquivo.target.startsWith("~/")) return arquivo.target.slice(2);
127
+ if (arquivo.target.startsWith("components/")) {
128
+ return `${projeto.dirComponentes}/${arquivo.target.slice("components/".length)}`;
129
+ }
130
+ return `${projeto.prefixo}${arquivo.target}`;
131
+ }
132
+ if (tipo === "registry:ui") return `${projeto.dirUi}/${nome}`;
133
+ if (tipo === "registry:component" || tipo === "registry:block") {
134
+ return `${projeto.dirComponentes}/${nome}`;
135
+ }
136
+ if (tipo === "registry:hook") return `${projeto.dirHooks}/${nome}`;
137
+ // lib/utils.ts recebe helpers do produto: fica fora da comparação.
138
+ if (tipo === "registry:lib" && LIBS_CONFERIDAS.has(nome)) {
139
+ return `${projeto.dirLib}/${nome}`;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /** Kits que o init instala, além de todos os primitivos. */
145
+ export const KITS = [
146
+ "kit-shell",
147
+ "kit-tabela",
148
+ "kit-formulario",
149
+ "kit-feedback",
150
+ "kit-dados",
151
+ ];
152
+
153
+ const LIBS_CONFERIDAS = new Set(["format.ts", "motion.ts", "range.ts"]);
154
+
155
+ /** "joaodadas/lume/button#v0.4.0" (ou uma URL de item) vira "button". */
156
+ function nomeDaDependencia(dep) {
157
+ return String(dep)
158
+ .replace(/#.*$/, "")
159
+ .split("/")
160
+ .pop()
161
+ .replace(/\.json$/, "");
162
+ }
163
+
164
+ /**
165
+ * Os itens que o lume de fato pôs no projeto: todos os primitivos, os kits do
166
+ * init (kit-graficos quando o projeto tem recharts), login-form, guia,
167
+ * link-next no Next e o que o lume.json lista em `itens`, com as dependências
168
+ * de cada um. Componente do produto com nome igual a um item fora disso não é
169
+ * do lume e não é comparado.
170
+ */
171
+ export function itensDoLume(itens, projeto) {
172
+ const porNome = new Map(itens.map((i) => [i.name, i]));
173
+ const raizes = [
174
+ ...itens.filter((i) => i.type === "registry:ui").map((i) => i.name),
175
+ ...KITS,
176
+ ...(projeto.deps?.recharts ? ["kit-graficos"] : []),
177
+ "login-form",
178
+ "guia",
179
+ ...(projeto.framework === "next" ? ["link-next"] : []),
180
+ ...(Array.isArray(projeto.lumeJson?.itens) ? projeto.lumeJson.itens : []),
181
+ ];
182
+ const vistos = new Set();
183
+ const fila = [...raizes];
184
+ while (fila.length > 0) {
185
+ const nome = fila.pop();
186
+ if (vistos.has(nome) || !porNome.has(nome)) continue;
187
+ vistos.add(nome);
188
+ for (const dep of porNome.get(nome).registryDependencies ?? []) {
189
+ fila.push(nomeDaDependencia(dep));
190
+ }
191
+ }
192
+ return vistos;
193
+ }
194
+
195
+ const ALIASES_ESPERADOS = {
196
+ components: /\/components$/,
197
+ ui: /\/components\/ui$/,
198
+ utils: /\/lib\/utils$/,
199
+ };
200
+
201
+ const MARCAS_BASE = [
202
+ "--custom-shadow",
203
+ "@toolwind/corner-shape",
204
+ "shadcn/tailwind.css",
205
+ ];
206
+
207
+ /**
208
+ * A base do lume está instalada? A mesma régua do check: components.json do
209
+ * lume, os três aliases e as marcas da base no CSS global ou num que ele
210
+ * importa.
211
+ */
212
+ export function baseInstalada(projeto) {
213
+ const cj = projeto.componentsJson;
214
+ if (!cj || cj.__invalido) return false;
215
+ if (cj.style !== "base-vega" || cj.iconLibrary !== "tabler") return false;
216
+ if (cj.tsx === false) return false;
217
+ const a = cj.aliases ?? {};
218
+ for (const [chave, re] of Object.entries(ALIASES_ESPERADOS)) {
219
+ if (!re.test(String(a[chave] ?? ""))) return false;
220
+ }
221
+ if (!projeto.css) return false;
222
+ const junto = cssComImports(projeto.raiz, projeto.css)
223
+ .map((c) => c.css)
224
+ .join("\n");
225
+ return MARCAS_BASE.every((m) => junto.includes(m));
226
+ }
227
+
228
+ function add(violacoes, regra, arquivo, linha, trecho) {
229
+ violacoes.push({
230
+ regra,
231
+ arquivo,
232
+ linha: linha ?? null,
233
+ trecho: trecho ?? null,
234
+ });
235
+ }
236
+
237
+ function conferirConfig(projeto, violacoes) {
238
+ const lj = projeto.lumeJson;
239
+ if (!lj) add(violacoes, "config/lume-json", "lume.json", null, "não existe");
240
+ else if (lj.__invalido)
241
+ add(violacoes, "config/lume-json", "lume.json", null, "JSON inválido");
242
+ else {
243
+ if (!/^\d+\.\d+\.\d+$/.test(String(lj.versao ?? ""))) {
244
+ add(
245
+ violacoes,
246
+ "config/lume-json",
247
+ "lume.json",
248
+ null,
249
+ "versao ausente ou fora do formato X.Y.Z",
250
+ );
251
+ }
252
+ if (!LAYOUTS.includes(lj.layout)) {
253
+ add(
254
+ violacoes,
255
+ "config/lume-json",
256
+ "lume.json",
257
+ null,
258
+ `layout precisa ser ${LAYOUTS.join(", ")}`,
259
+ );
260
+ }
261
+ if (
262
+ lj.itens !== undefined &&
263
+ (!Array.isArray(lj.itens) || lj.itens.some((x) => typeof x !== "string"))
264
+ ) {
265
+ add(
266
+ violacoes,
267
+ "config/lume-json",
268
+ "lume.json",
269
+ null,
270
+ "itens precisa ser uma lista de nomes de item",
271
+ );
272
+ }
273
+ for (const [nome, motivo] of Object.entries(lj.extras ?? {})) {
274
+ if (typeof motivo !== "string" || motivo.trim().length < 8) {
275
+ add(
276
+ violacoes,
277
+ "config/lume-json",
278
+ "lume.json",
279
+ null,
280
+ `extras.${nome} sem motivo`,
281
+ );
282
+ }
283
+ }
284
+ }
285
+ const cj = projeto.componentsJson;
286
+ if (!cj || cj.__invalido) {
287
+ add(
288
+ violacoes,
289
+ "config/components-json",
290
+ "components.json",
291
+ null,
292
+ cj ? "JSON inválido" : "não existe",
293
+ );
294
+ return;
295
+ }
296
+ if (cj.style !== "base-vega") {
297
+ add(
298
+ violacoes,
299
+ "config/components-json",
300
+ "components.json",
301
+ null,
302
+ `style "${cj.style}"`,
303
+ );
304
+ }
305
+ if (cj.iconLibrary !== "tabler") {
306
+ add(
307
+ violacoes,
308
+ "config/components-json",
309
+ "components.json",
310
+ null,
311
+ `iconLibrary "${cj.iconLibrary}"`,
312
+ );
313
+ }
314
+ if (cj.tsx === false)
315
+ add(
316
+ violacoes,
317
+ "config/components-json",
318
+ "components.json",
319
+ null,
320
+ "tsx false",
321
+ );
322
+ const a = cj.aliases ?? {};
323
+ for (const [chave, re] of Object.entries(ALIASES_ESPERADOS)) {
324
+ if (!re.test(String(a[chave] ?? ""))) {
325
+ add(
326
+ violacoes,
327
+ "config/alias",
328
+ "components.json",
329
+ null,
330
+ `aliases.${chave} = "${a[chave]}"`,
331
+ );
332
+ }
333
+ }
334
+ }
335
+
336
+ /** O CSS global e os arquivos que ele importa por caminho relativo. */
337
+ export function cssComImports(raiz, rel, vistos = new Set()) {
338
+ if (vistos.has(rel) || !existsSync(path.join(raiz, rel))) return [];
339
+ vistos.add(rel);
340
+ const css = semComentariosCss(readFileSync(path.join(raiz, rel), "utf8"));
341
+ const saida = [{ rel, css }];
342
+ for (const m of css.matchAll(/@import\s+["'](\.{1,2}\/[^"']+\.css)["']/g)) {
343
+ const proximo = path.posix.normalize(
344
+ path.posix.join(path.posix.dirname(rel), m[1]),
345
+ );
346
+ saida.push(...cssComImports(raiz, proximo, vistos));
347
+ }
348
+ return saida;
349
+ }
350
+
351
+ function conferirCss(projeto, violacoes, arquivosDoProjeto) {
352
+ if (!projeto.css) {
353
+ add(violacoes, "css/base", "(CSS global)", null, "não achei o CSS global");
354
+ return;
355
+ }
356
+ const doGlobal = cssComImports(projeto.raiz, projeto.css);
357
+ const junto = doGlobal.map((c) => c.css).join("\n");
358
+ for (const marca of MARCAS_BASE) {
359
+ if (!junto.includes(marca))
360
+ add(violacoes, "css/base", projeto.css, null, `falta ${marca}`);
361
+ }
362
+ const escopo = escopoDoApp(projeto);
363
+ const todos = arquivosDoProjeto.filter(
364
+ (r) => r.endsWith(".css") && (escopo(r) || r === projeto.css),
365
+ );
366
+ for (const rel of todos) {
367
+ const css = semComentariosCss(
368
+ readFileSync(path.join(projeto.raiz, rel), "utf8"),
369
+ );
370
+ for (const m of css.matchAll(/font-feature-settings\s*:/g)) {
371
+ add(
372
+ violacoes,
373
+ "css/fonte",
374
+ rel,
375
+ linhaDe(css, m.index),
376
+ "font-feature-settings",
377
+ );
378
+ }
379
+ for (const m of css.matchAll(/--font-sans\s*:\s*([^;]+);/g)) {
380
+ if (!/^\s*["']Inter Variable["']/.test(m[1])) {
381
+ add(
382
+ violacoes,
383
+ "css/fonte",
384
+ rel,
385
+ linhaDe(css, m.index),
386
+ `--font-sans: ${m[1].trim().slice(0, 40)}`,
387
+ );
388
+ }
389
+ }
390
+ for (const m of css.matchAll(/@import\s+(?:url\()?["']([^"']+)["']/g)) {
391
+ const spec = m[1];
392
+ const fonte = /font|inter|googleapis/i.test(spec);
393
+ const permitida =
394
+ /^@fontsource-variable\/(inter|geist-mono|host-grotesk)/.test(spec);
395
+ if (fonte && !permitida)
396
+ add(violacoes, "css/fonte", rel, linhaDe(css, m.index), spec);
397
+ }
398
+ }
399
+ }
400
+
401
+ function conferirDeps(projeto, violacoes) {
402
+ for (const nome of Object.keys(projeto.deps)) {
403
+ if (DEPS_PROIBIDAS.some((re) => re.test(nome))) {
404
+ add(violacoes, "deps/proibida", "package.json", null, nome);
405
+ }
406
+ }
407
+ }
408
+
409
+ async function conferirRegistry(
410
+ projeto,
411
+ registry,
412
+ violacoes,
413
+ arquivosDoProjeto,
414
+ ) {
415
+ const itens = await registry.itens();
416
+ const nomesUi = new Set(
417
+ itens.filter((i) => i.type === "registry:ui").map((i) => i.name),
418
+ );
419
+ const doLume = itensDoLume(itens, projeto);
420
+ const instalados = new Map();
421
+ for (const item of itens) {
422
+ if (!doLume.has(item.name)) continue;
423
+ for (const arquivo of item.files ?? []) {
424
+ const destino = caminhoInstalado(arquivo, item.type, projeto);
425
+ if (!destino) continue;
426
+ if (!instalados.has(destino)) instalados.set(destino, []);
427
+ instalados.get(destino).push({ item: item.name, path: arquivo.path });
428
+ }
429
+ }
430
+ const existentes = [...instalados.keys()].filter((d) =>
431
+ existsSync(path.join(projeto.raiz, d)),
432
+ );
433
+ await emLotes(existentes, 8, async (destino) => {
434
+ const prefixos = projeto.prefixosLocais;
435
+ const local = normalizar(
436
+ readFileSync(path.join(projeto.raiz, destino), "utf8"),
437
+ prefixos,
438
+ );
439
+ for (const cand of instalados.get(destino)) {
440
+ const json = await registry.item(cand.item);
441
+ const f = (json.files ?? []).find((x) => x.path === cand.path);
442
+ if (f && normalizar(f.content ?? "", prefixos) === local) return;
443
+ }
444
+ const itensDoArquivo = instalados
445
+ .get(destino)
446
+ .map((c) => c.item)
447
+ .join(" ou ");
448
+ add(
449
+ violacoes,
450
+ "ui/editado",
451
+ destino,
452
+ null,
453
+ `difere de ${itensDoArquivo} da v${registry.versao}`,
454
+ );
455
+ });
456
+
457
+ const extras = projeto.lumeJson?.extras ?? {};
458
+ const prefixoUi = `${projeto.dirUi}/`;
459
+ for (const rel of arquivosDoProjeto) {
460
+ if (
461
+ !rel.startsWith(prefixoUi) ||
462
+ !CODIGO.test(rel) ||
463
+ NAO_ESCANEAR.test(rel)
464
+ )
465
+ continue;
466
+ const nome = path.posix.basename(rel).replace(CODIGO, "");
467
+ if (instalados.has(rel) || extras[nome]) continue;
468
+ add(violacoes, "ui/fora-da-registry", rel, null, nome);
469
+ }
470
+
471
+ const porPasta = new Map();
472
+ for (const rel of arquivosDoProjeto) {
473
+ if (!CODIGO.test(rel) || rel.startsWith(prefixoUi) || instalados.has(rel))
474
+ continue;
475
+ const nome = path.posix.basename(rel).replace(CODIGO, "");
476
+ if (!nomesUi.has(nome)) continue;
477
+ const pasta = path.posix.dirname(rel);
478
+ if (!porPasta.has(pasta)) porPasta.set(pasta, []);
479
+ porPasta.get(pasta).push(rel);
480
+ }
481
+ for (const [pasta, arquivos] of porPasta) {
482
+ if (arquivos.length >= 4) {
483
+ add(
484
+ violacoes,
485
+ "ui/paralelo",
486
+ pasta,
487
+ null,
488
+ `${arquivos.length} primitivos com nome do lume`,
489
+ );
490
+ }
491
+ }
492
+ return { instalados };
493
+ }
494
+
495
+ function conferirCodigo(projeto, instalados, violacoes, arquivosDoProjeto) {
496
+ const escopo = escopoDoApp(projeto);
497
+ const prefixoUi = `${projeto.dirUi}/`;
498
+ for (const rel of arquivosDoProjeto) {
499
+ if (!CODIGO.test(rel) || NAO_ESCANEAR.test(rel) || !escopo(rel)) continue;
500
+ if (rel.startsWith(prefixoUi) || instalados.has(rel)) continue;
501
+ const bruto = readFileSync(path.join(projeto.raiz, rel), "utf8");
502
+ const linhas = bruto.split("\n");
503
+ const codigo = semComentarios(bruto);
504
+ const excecao = (linha) =>
505
+ EXCECAO.test(linhas[linha - 1] ?? "") ||
506
+ EXCECAO.test(linhas[linha - 2] ?? "");
507
+ for (const m of codigo.matchAll(IMPORTS_PROIBIDOS)) {
508
+ const linha = linhaDe(codigo, m.index);
509
+ if (!excecao(linha)) add(violacoes, "codigo/import", rel, linha, m[1]);
510
+ }
511
+ for (const [regra, re] of PADROES_CODIGO) {
512
+ for (const m of codigo.matchAll(re)) {
513
+ const linha = linhaDe(codigo, m.index);
514
+ if (!excecao(linha)) add(violacoes, regra, rel, linha, m[0]);
515
+ }
516
+ }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Confere o projeto em `raiz`. `versao` vem do lume.json (ou do argumento) e
522
+ * escolhe a tag da registry contra a qual os arquivos são comparados.
523
+ */
524
+ export async function conferir(raiz, opcoes = {}) {
525
+ const lido = lerProjeto(raiz);
526
+ // Com o alias desviado (components/lume/ui, components/ds), a conferência
527
+ // usa as pastas padrão: é lá que o lume tem que estar.
528
+ const projeto = {
529
+ ...lido,
530
+ dirUi: /\/components\/ui$/.test(
531
+ lido.componentsJson?.aliases?.ui ?? "@/components/ui",
532
+ )
533
+ ? lido.dirUi
534
+ : `${lido.prefixo}components/ui`,
535
+ dirComponentes: /\/components$/.test(
536
+ lido.componentsJson?.aliases?.components ?? "@/components",
537
+ )
538
+ ? lido.dirComponentes
539
+ : `${lido.prefixo}components`,
540
+ };
541
+ const violacoes = [];
542
+ const versao =
543
+ opcoes.versao ?? projeto.lumeJson?.versao ?? opcoes.versaoPadrao;
544
+ conferirConfig(projeto, violacoes);
545
+ const arquivos = listar(raiz);
546
+ conferirCss(projeto, violacoes, arquivos);
547
+ conferirDeps(projeto, violacoes);
548
+ const registry = criarRegistry(versao, opcoes.buscar);
549
+ registry.versao = versao;
550
+ const { instalados } = await conferirRegistry(
551
+ projeto,
552
+ registry,
553
+ violacoes,
554
+ arquivos,
555
+ );
556
+ conferirCodigo(projeto, instalados, violacoes, arquivos);
557
+ return {
558
+ ok: violacoes.length === 0,
559
+ adotou: adotouLume(projeto),
560
+ versao,
561
+ layout: projeto.lumeJson?.layout ?? null,
562
+ violacoes,
563
+ };
564
+ }
565
+
566
+ /** Relatório legível, agrupado por regra, com até `max` ocorrências cada. */
567
+ export function relatorio(resultado, max = 15) {
568
+ if (resultado.ok) {
569
+ return `lume check: tudo certo (v${resultado.versao}, layout ${resultado.layout}).`;
570
+ }
571
+ const porRegra = new Map();
572
+ for (const v of resultado.violacoes) {
573
+ if (!porRegra.has(v.regra)) porRegra.set(v.regra, []);
574
+ porRegra.get(v.regra).push(v);
575
+ }
576
+ const linhas = [
577
+ `lume check: ${resultado.violacoes.length} violações em ${porRegra.size} regras (v${resultado.versao}).`,
578
+ "",
579
+ ];
580
+ for (const [regra, lista] of porRegra) {
581
+ linhas.push(`${regra} (${lista.length}): ${REGRAS[regra] ?? ""}`);
582
+ for (const v of lista.slice(0, max)) {
583
+ const onde = v.linha ? `${v.arquivo}:${v.linha}` : v.arquivo;
584
+ linhas.push(` ${onde}${v.trecho ? ` ${v.trecho}` : ""}`);
585
+ }
586
+ if (lista.length > max) linhas.push(` ... e mais ${lista.length - max}`);
587
+ linhas.push("");
588
+ }
589
+ linhas.push(
590
+ "Exceção de código, quando a regra não cabe de verdade: comentário `lume:excecao motivo=<o porquê>` na linha ou na de cima.",
591
+ );
592
+ return linhas.join("\n");
593
+ }