synthesisui 0.16.251 → 0.16.253

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 { basename, dirname, join, relative } from "node:path";
3
3
  import { anatomyFromSketch } from "../anatomy-from-sketch.js";
4
4
  import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
5
5
  import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
6
+ import { declaredElsewhere } from "../declared-elsewhere.js";
6
7
  import { architectureRule, describeArchitecture, describeChoice, detectArchitectures, } from "../doctor/architecture.js";
7
8
  import { findBrokenRefs } from "../doctor/broken-refs.js";
8
9
  import { nestingRules, propRules, readDefinitionProps, readNesting, readRuntime, } from "../doctor/call-sites.js";
@@ -34,9 +35,11 @@ import { definitionSpan, parseClass, readInlineStyle, rootClasses, rootTag, tran
34
35
  import { transcribeVariants } from "../doctor/variant-read.js";
35
36
  import { frontierKind, packageRoot } from "../frontier-kind.js";
36
37
  import { withLibraryStructure } from "../library-structure.js";
38
+ import { claimName } from "../name-claim.js";
37
39
  import { body, paint, section } from "../output.js";
38
40
  import { phase, startProgress } from "../progress.js";
39
41
  import { detectStack, resolveDeps, stackVersions } from "../stack.js";
42
+ import { placeInWorkspace } from "../workspace-place.js";
40
43
  import { walk, walkAll } from "./doctor.js";
41
44
  /**
42
45
  * How many distinct values travel, PER KIND.
@@ -392,6 +395,16 @@ export async function takeCensus(root, opts) {
392
395
  const internal = await internalSpecifiers(root);
393
396
  const defined = [];
394
397
  /** Nome → arquivo e fonte, para toda leitura por componente - ver o preenchimento abaixo. */
398
+ /**
399
+ * QUEM DETÉM CADA NOME - e o registro de quem pediu o mesmo.
400
+ *
401
+ * Os oito mapas deste laço são indexados por nome de componente, então dois
402
+ * `Card.tsx` disputam a mesma chave e o último do `walk` sobrescrevia os
403
+ * outros em silêncio. Medido no repo do dono: 20 nomes colidem no repo
404
+ * inteiro, 14 dentro de um app, e um dos quatro `Card` é o do design system
405
+ * dele. Ver `name-claim.ts` para o desempate e por que ele é por estrutura.
406
+ */
407
+ const claims = { by: new Map(), collisions: [] };
395
408
  const sourceOf = new Map();
396
409
  /** Nome → o que a peça é, quando o portão soube dizer. Ver `CensusLook.kind`. */
397
410
  const kindOf = new Map();
@@ -590,6 +603,21 @@ export async function takeCensus(root, opts) {
590
603
  because: verdict.because,
591
604
  });
592
605
  }
606
+ /**
607
+ * O NOME É DISPUTADO AQUI, ANTES DE QUALQUER ESCRITA.
608
+ *
609
+ * Filtrar `found` fecha a porta de uma vez: os oito mapas deste laço
610
+ * derivam dele, então um componente que não detém o nome simplesmente não
611
+ * chega a nenhum deles - em vez de oito guardas que alguém esquece de
612
+ * repetir na nona escrita.
613
+ *
614
+ * O descarte NÃO é silencioso: `claims.collisions` guarda os dois caminhos
615
+ * e o censo os carrega. Trazer as duas receitas exige o contexto viajando
616
+ * na chave, que é a fatia dos grupos.
617
+ */
618
+ const claimed = found.filter((d) => claimName(claims, d.name, rel));
619
+ found.length = 0;
620
+ found.push(...claimed);
593
621
  for (const d of found) {
594
622
  const props = readDefinitionProps(d.name, src);
595
623
  propsOf.set(d.name, props);
@@ -1878,6 +1906,14 @@ export async function takeCensus(root, opts) {
1878
1906
  ...(Object.keys(keyframes).length > 0 ? { keyframes } : {}),
1879
1907
  ...(animations.size > 0 ? { animations: [...animations].sort() } : {}),
1880
1908
  ...(brokenRefs.length > 0 ? { brokenRefs } : {}),
1909
+ /**
1910
+ * OS NOMES DISPUTADOS - ver `name-claim.ts`.
1911
+ *
1912
+ * Aditivo e omitido quando vazio, que é o caso de toda biblioteca sã: no repo
1913
+ * do dono, `packages/ui` produz zero e o repo inteiro produz 20. Um campo que
1914
+ * só aparece quando há algo a dizer não muda nada para quem já lê o censo.
1915
+ */
1916
+ ...(claims.collisions.length > 0 ? { collisions: claims.collisions } : {}),
1881
1917
  ...(conventions.length > 0 ? { conventions } : {}),
1882
1918
  classStyle,
1883
1919
  ...(Object.keys(looks).length > 0 ? { looks } : {}),
@@ -1996,7 +2032,15 @@ export async function takeCensus(root, opts) {
1996
2032
  },
1997
2033
  };
1998
2034
  }
1999
- function summarize(c) {
2035
+ /**
2036
+ * O RESUMO RECEBE O LUGAR JUNTO COM O CENSO - e não a metade dele.
2037
+ *
2038
+ * `sayBrokenRefs` precisa saber ONDE procurar a declaração que o escopo não viu,
2039
+ * e um parâmetro opcional aqui seria a mesma porta que o §6 do método manda
2040
+ * fechar: uma função que pode ser chamada pela metade vai ser, e o sintoma seria
2041
+ * o relatório voltando a dizer "your CSS never declares" sem que nada falhe.
2042
+ */
2043
+ async function summarize(c, root, scope) {
2000
2044
  const byKind = new Map();
2001
2045
  for (const v of c.observed)
2002
2046
  byKind.set(v.kind, (byKind.get(v.kind) ?? 0) + v.count);
@@ -2045,7 +2089,36 @@ function summarize(c) {
2045
2089
  }
2046
2090
  }
2047
2091
  sayConventions(c);
2048
- sayBrokenRefs(c);
2092
+ sayCollisions(c);
2093
+ await sayBrokenRefs(c, root, scope);
2094
+ }
2095
+ /**
2096
+ * DOIS ARQUIVOS, UM NOME - dito, com os dois caminhos.
2097
+ *
2098
+ * O silêncio aqui era o pior tipo: a pessoa recebe 36 componentes onde o repo
2099
+ * tem 39, e nada no relatório sugere que três existiram. Medido no monorepo do
2100
+ * dono: 20 nomes no repo inteiro, e um dos quatro `Card.tsx` é o do design system
2101
+ * dele competindo com dois que vivem sob rotas.
2102
+ *
2103
+ * A frase diz o que fazer, e são duas coisas diferentes: um escopo mais estreito
2104
+ * quando os homônimos são de outro projeto, ou um rename quando são do mesmo.
2105
+ */
2106
+ function sayCollisions(c) {
2107
+ const all = c.collisions ?? [];
2108
+ if (all.length === 0)
2109
+ return;
2110
+ const names = new Set(all.map((x) => x.name));
2111
+ console.log("");
2112
+ console.log(section("Two files, one name"));
2113
+ console.log(body(`${names.size} name${names.size === 1 ? "" : "s"} ${names.size === 1 ? "is" : "are"} defined more than once in what was measured, so ${all.length} definition${all.length === 1 ? "" : "s"} did not make it into this system. The one closest to the root of the scope kept the name - never the order the disk happened to return.`));
2114
+ console.log("");
2115
+ for (const x of all.slice(0, 6)) {
2116
+ console.log(body(`${paint.strong(x.name)} ${paint.faint("kept")} ${x.kept}\n ${paint.faint("dropped")} ${x.dropped}`));
2117
+ }
2118
+ if (all.length > 6)
2119
+ console.log(body(paint.faint(`(${all.length - 6} more)`)));
2120
+ console.log("");
2121
+ console.log(body(paint.faint("If the homonyms belong to different projects, a narrower --scope is the answer and nothing is lost. If they belong to the same one, renaming in your code is - two components with one name is one name your agent cannot resolve either.")));
2049
2122
  }
2050
2123
  /**
2051
2124
  * THEIR VOCABULARY, MEASURED. This section exists so every later finding can be
@@ -2076,14 +2149,70 @@ function sayConventions(c) {
2076
2149
  * finding that needs no agreement with us: the convention being broken is
2077
2150
  * theirs.
2078
2151
  */
2079
- function sayBrokenRefs(c) {
2080
- const broken = c.brokenRefs ?? [];
2081
- if (broken.length === 0)
2152
+ /**
2153
+ * A PASTA QUE CONTÉM TODOS ESTES CAMINHOS - o `--scope` que traria as
2154
+ * declarações junto, derivado dos endereços e não sugerido de cabeça.
2155
+ *
2156
+ * Devolve `null` quando os caminhos não compartilham pasta nenhuma: aí não existe
2157
+ * um escopo único que resolva, e inventar um seria pior que dizer a regra geral.
2158
+ */
2159
+ function commonPrefix(paths) {
2160
+ if (paths.length === 0)
2161
+ return null;
2162
+ const out = [];
2163
+ for (let i = 0;; i++) {
2164
+ const seg = paths[0][i];
2165
+ // O último segmento é o arquivo, e um arquivo não é um escopo.
2166
+ if (seg == null || i >= paths[0].length - 1)
2167
+ break;
2168
+ if (!paths.every((p) => p[i] === seg && i < p.length - 1))
2169
+ break;
2170
+ out.push(seg);
2171
+ }
2172
+ return out.length > 0 ? out.join("/") : null;
2173
+ }
2174
+ async function sayBrokenRefs(c, root, scope) {
2175
+ const all = c.brokenRefs ?? [];
2176
+ if (all.length === 0)
2082
2177
  return;
2178
+ /**
2179
+ * DUAS PERGUNTAS DIFERENTES, E O RELATÓRIO FAZIA UMA SÓ.
2180
+ *
2181
+ * "Não resolve no que eu medi" e "não existe no seu código" são coisas
2182
+ * distintas, e o texto afirmava a segunda. Com `--scope src/components` no
2183
+ * monorepo do dono são 1021 referências contra 2 declarações, enquanto o CSS
2184
+ * dele declara 118 em `src/app/**` e 764 em `packages/ui` (medido 19/08).
2185
+ *
2186
+ * Culpar o CSS de quem confiou o repositório por um recorte NOSSO é o pior
2187
+ * lugar para errar: o relatório é o que a pessoa lê depois, sozinha.
2188
+ */
2189
+ const at = await declaredElsewhere(root, scope, all.map((b) => b.name)).catch(() => new Map());
2190
+ const outside = all.filter((b) => at.has(b.name));
2191
+ const broken = all.filter((b) => !at.has(b.name));
2192
+ if (outside.length > 0) {
2193
+ const uses = outside.reduce((n, b) => n + b.count, 0);
2194
+ console.log("");
2195
+ console.log(section("These are declared outside what was measured"));
2196
+ console.log(body(`${uses} reference${uses === 1 ? "" : "s"} to ${outside.length} token${outside.length === 1 ? "" : "s"} your code DOES declare - just not inside ${paint.strong(scope ?? "this folder")}. Your CSS is fine; the recipes carry the value you typed instead of the name you gave it, because the name was not in what we read.`));
2197
+ console.log("");
2198
+ for (const b of outside.slice(0, 6)) {
2199
+ console.log(body(`${paint.strong(`var(${b.name})`)} ${paint.faint(`${b.count}× here`)} - declared in ${paint.strong(at.get(b.name) ?? "")}`));
2200
+ }
2201
+ if (outside.length > 6)
2202
+ console.log(body(paint.faint(`(${outside.length - 6} more)`)));
2203
+ console.log("");
2204
+ /** O caminho comum dos endereços é o escopo que traria todos de uma vez. */
2205
+ const wider = commonPrefix([...at.values()].map((v) => v.split("/")));
2206
+ console.log(body(wider
2207
+ ? `A wider ${paint.strong("--scope")} keeps your names: ${paint.strong(`--scope ${wider}`)} covers the declarations above.`
2208
+ : "A wider --scope keeps your names, because the declarations come along with the code that uses them."));
2209
+ if (broken.length === 0)
2210
+ return;
2211
+ }
2083
2212
  const uses = broken.reduce((n, b) => n + b.count, 0);
2084
2213
  console.log("");
2085
2214
  console.log(section("These resolve to nothing"));
2086
- console.log(body(`${uses} reference${uses === 1 ? "" : "s"} to ${broken.length} token${broken.length === 1 ? "" : "s"} your CSS never declares. A ${paint.strong("var()")} with no declaration paints no colour - not a different colour. Where exactly one declared token differs by a namespace, the recipe now uses THAT one, so the look survives; the rest travel as the value you typed.`));
2215
+ console.log(body(`${uses} reference${uses === 1 ? "" : "s"} to ${broken.length} token${broken.length === 1 ? "" : "s"} nothing in this repository declares. A ${paint.strong("var()")} with no declaration paints no colour - not a different colour. Where exactly one declared token differs by a namespace, the recipe now uses THAT one, so the look survives; the rest travel as the value you typed.`));
2087
2216
  console.log("");
2088
2217
  for (const b of broken.slice(0, 8)) {
2089
2218
  const where = `${b.count}× in ${b.files} file${b.files === 1 ? "" : "s"}`;
@@ -2286,6 +2415,75 @@ function printAgentContract() {
2286
2415
  * just stops letting a person believe the average of three apps is their design
2287
2416
  * system.
2288
2417
  */
2418
+ /**
2419
+ * O LUGAR, DITO ANTES DO ESCOPO - e o comando que a resposta implica.
2420
+ *
2421
+ * A detecção de monorepo olhava só para BAIXO (`siblingProjects` lê `apps/`,
2422
+ * `packages/`, `libs/` dentro do root), então quem roda o import de dentro do app
2423
+ * que está migrando recebia silêncio: `apps/web-dashboard` não contém `apps/`.
2424
+ * Medido no monorepo do dono (19/08): 5 pacotes, `packages/ui` declarando 764
2425
+ * tokens em 3 arquivos contra 114 em 6 no app onde ele rodou, e o aviso que
2426
+ * ensina `--scope packages/ui --usage apps/web-dashboard` nunca apareceu.
2427
+ *
2428
+ * A ORDEM É A DA DÚVIDA DE QUEM LÊ: onde eu estou, onde mora o vocabulário, e só
2429
+ * então o comando. Um comando oferecido antes das duas primeiras respostas é uma
2430
+ * receita para copiar sem entender.
2431
+ */
2432
+ async function sayAboutPlace(root, scope) {
2433
+ const place = await placeInWorkspace(root);
2434
+ /** Sem workspace acima, a pergunta antiga ainda vale: há projetos AQUI dentro? */
2435
+ if (!place) {
2436
+ if (!scope)
2437
+ await sayIfWorkspace(root);
2438
+ return;
2439
+ }
2440
+ /** Na raiz do workspace o aviso que já existia é o certo - ele lista os apps. */
2441
+ if (place.here === null) {
2442
+ if (!scope)
2443
+ await sayIfWorkspace(root);
2444
+ return;
2445
+ }
2446
+ const vocab = place.vocabulary;
2447
+ const mine = place.packages.find((p) => p.rel === place.here);
2448
+ /**
2449
+ * NADA A DIZER quando o vocabulário está aqui mesmo: a pessoa escolheu o
2450
+ * pacote que declara os tokens, e repetir o que ela acertou é ruído.
2451
+ */
2452
+ if (!vocab || vocab.rel === place.here)
2453
+ return;
2454
+ /** Nem quando o escopo dado já aponta para lá - ela já sabe. */
2455
+ if (scope && join(place.here, scope) === vocab.rel)
2456
+ return;
2457
+ const up = relative(root, place.workspaceRoot).split("\\").join("/") || ".";
2458
+ /**
2459
+ * SÓ QUEM CONSOME A BIBLIOTECA doa evidência. Oferecer `--usage apps/web-admin`
2460
+ * para um app que nunca a importa ensina a medir escolhas de um lugar que não
2461
+ * vota - e `--usage` existe justamente para dizer quais escolhas já são lei.
2462
+ * Medido no monorepo do dono: dos dois outros apps, nenhum declara a
2463
+ * biblioteca, e a primeira versão desta mensagem oferecia os dois.
2464
+ */
2465
+ const consumers = place.packages.filter((p) => p.rel !== vocab.rel &&
2466
+ p.rel !== place.here &&
2467
+ vocab.name != null &&
2468
+ p.dependsOn.includes(vocab.name));
2469
+ const usage = [place.here, ...consumers.map((c) => c.rel)]
2470
+ .slice(0, 3)
2471
+ .map((a) => `--usage ${a}`)
2472
+ .join(" ");
2473
+ console.log("");
2474
+ console.log(section("This folder is one package of a workspace"));
2475
+ console.log(body(`${paint.strong(place.here)} is one of ${place.packages.length} packages under ${paint.strong(place.workspaceRoot)}.`));
2476
+ console.log("");
2477
+ console.log(body(`And the vocabulary is not here: ${paint.strong(vocab.rel)} declares ${vocab.declarations} token${vocab.declarations === 1 ? "" : "s"} across ${vocab.files} file${vocab.files === 1 ? "" : "s"}, while this package declares ${mine?.declarations ?? 0} across ${mine?.files ?? 0}.`));
2478
+ console.log(body(paint.faint("A system built from the app is the average of one consumer; the tokens live where the library declares them.")));
2479
+ console.log("");
2480
+ console.log(body("The system is one place, and the evidence is another:"));
2481
+ console.log("");
2482
+ console.log(body(` ${paint.strong(`synthesisui import --dir ${up} --scope ${vocab.rel} ${usage}`)}`));
2483
+ console.log(body(paint.faint(` --dir points at the workspace root, so --scope and --usage are read from there.`)));
2484
+ console.log("");
2485
+ console.log(body(paint.dim("Nothing you ran is wasted - this census is a fine diagnosis of this app. It is a system for one consumer, which is the part worth knowing before you publish it.")));
2486
+ }
2289
2487
  async function sayIfWorkspace(root) {
2290
2488
  const { apps, shared } = await siblingProjects(root);
2291
2489
  if (apps.length < 2)
@@ -3058,11 +3256,22 @@ export async function runImport(opts) {
3058
3256
  */
3059
3257
  if (!opts.census)
3060
3258
  await resolveReadParts(census, root);
3061
- summarize(census);
3062
- // Only when nobody has scoped yet. Telling someone to scope to the folder
3063
- // they just scoped to reads as the tool not having noticed.
3064
- if (!opts.census && !scope)
3065
- await sayIfWorkspace(root);
3259
+ await summarize(census, root, scope);
3260
+ /**
3261
+ * ONDE ESTOU vem antes de O QUE EU LEIO, e o `!scope` calava exatamente quem
3262
+ * mais precisava ouvir.
3263
+ *
3264
+ * A condição era "só quando ninguém escopou ainda", pelo motivo certo: mandar
3265
+ * escopar para a pasta que a pessoa acabou de escopar lê como a ferramenta não
3266
+ * ter percebido. Só que ela também calava o caso em que o escopo escolhido não
3267
+ * é onde o vocabulário mora - e aí o silêncio não é discrição, é perder a
3268
+ * única chance de dizer que o sistema está em outro pacote.
3269
+ *
3270
+ * `sayAboutPlace` faz as duas perguntas na ordem: onde este comando está, e
3271
+ * onde as declarações estão. Ele só fala quando as respostas divergem.
3272
+ */
3273
+ if (!opts.census)
3274
+ await sayAboutPlace(root, scope);
3066
3275
  // A census handed to us is written BACK TO ITSELF; one we took lands at the
3067
3276
  // ROOT, whatever it measured. Everything else in `_synthesisui/` is anchored
3068
3277
  // there - config, `ds/<slug>/`, the hook's marker - and the census was the
@@ -0,0 +1,77 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { placeInWorkspace } from "./workspace-place.js";
4
+ /**
5
+ * ONDE ESTE TOKEN É DECLARADO, QUANDO NÃO É NO QUE FOI MEDIDO.
6
+ *
7
+ * `findBrokenRefs` compara os `var()` do escopo contra as declarações do escopo,
8
+ * e o relatório chama o resto de *"tokens your CSS never declares"*. Com
9
+ * `--scope` isso pode ser falso, e no monorepo do dono era (19/08):
10
+ * `--scope src/components` deixa 1021 referências contra 2 declarações, e o CSS
11
+ * dele declara 118 em `src/app/**` e 764 em `packages/ui`.
12
+ *
13
+ * Afirmar um defeito que não existe no código de quem confiou o repositório é
14
+ * pior do que não dizer nada - o relatório é o documento que a pessoa lê depois,
15
+ * sozinha, e ele estava culpando o CSS dela por uma escolha de recorte NOSSA.
16
+ *
17
+ * Então a pergunta muda de "existe?" para "existe ONDE?", em duas camadas: fora
18
+ * do escopo mas dentro do que foi apontado, e nos outros pacotes do mesmo
19
+ * workspace - que é onde uma biblioteca de design system mora.
20
+ */
21
+ const SHEET = /\.(css|scss|sass|less)$/i;
22
+ const DECL = /(^|[\s{;])(--[a-zA-Z0-9-]+)\s*:/g;
23
+ /** Onde cada nome aparece declarado, primeiro achado ganha - basta um endereço. */
24
+ async function harvest(dir, label, skip, into) {
25
+ const visit = async (at, depth) => {
26
+ if (depth > 8)
27
+ return;
28
+ for (const e of await readdir(at, { withFileTypes: true }).catch(() => [])) {
29
+ if (e.name.startsWith(".") || e.name === "node_modules")
30
+ continue;
31
+ const full = join(at, e.name);
32
+ if (skip && full === skip)
33
+ continue;
34
+ if (e.isDirectory()) {
35
+ await visit(full, depth + 1);
36
+ continue;
37
+ }
38
+ if (!SHEET.test(e.name))
39
+ continue;
40
+ const body = await readFile(full, "utf8").catch(() => "");
41
+ for (const m of body.matchAll(DECL)) {
42
+ const name = m[2];
43
+ if (!into.has(name))
44
+ into.set(name, `${label}${relative(dir, full).split("\\").join("/")}`);
45
+ }
46
+ }
47
+ };
48
+ await visit(dir, 0);
49
+ }
50
+ /**
51
+ * NOME -> CAMINHO onde ele é declarado, fora do que foi medido.
52
+ *
53
+ * Só roda quando há algo a explicar: sem referências pendentes não há pergunta,
54
+ * e um walk a mais por nada é um walk a mais.
55
+ */
56
+ export async function declaredElsewhere(root, scope, names) {
57
+ const out = new Map();
58
+ if (names.length === 0)
59
+ return out;
60
+ const wanted = new Set(names);
61
+ const found = new Map();
62
+ /** Camada 1: o resto do que a pessoa apontou, sem reler o escopo. */
63
+ await harvest(root, "", scope ? join(root, scope) : null, found);
64
+ /** Camada 2: os outros pacotes do workspace - onde uma biblioteca mora. */
65
+ const place = await placeInWorkspace(root);
66
+ if (place) {
67
+ for (const pkg of place.packages) {
68
+ if (pkg.rel === place.here)
69
+ continue;
70
+ await harvest(join(place.workspaceRoot, pkg.rel), `${pkg.rel}/`, null, found);
71
+ }
72
+ }
73
+ for (const [name, where] of found)
74
+ if (wanted.has(name))
75
+ out.set(name, where);
76
+ return out;
77
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * UM NOME, UM COMPONENTE - e o que fazer quando dois pedem o mesmo.
3
+ *
4
+ * O laço de leitura do import escreve em oito mapas indexados por NOME de
5
+ * componente (`looks`, `sourceOf`, `kindOf`, `propsOf`, `runtimeOf`,
6
+ * `variantAxes`, `islands`, `keyframes`). Dois `Card.tsx` em pastas diferentes
7
+ * disputam a mesma chave, e até aqui o último que o `walk` visitou sobrescrevia
8
+ * os outros - em silêncio, e por uma ordem que é do sistema de arquivos e não
9
+ * uma decisão de ninguém.
10
+ *
11
+ * Medido no monorepo do dono (19/08): 20 nomes colidem no repo inteiro, 14 dentro
12
+ * de `apps/web-dashboard/src`, e quatro arquivos se chamam `Card.tsx` - um deles
13
+ * é o `Card` do design system dele, e dois estão sob `_legacy`. `packages/ui`
14
+ * sozinho não tem colisão nenhuma: o problema mora em escopo largo, que é
15
+ * exatamente o default que a flexibilidade pede.
16
+ *
17
+ * ─────────────────────────────────────────────────────────────────────────
18
+ * O DESEMPATE É POR ESTRUTURA, E DETERMINÍSTICO
19
+ *
20
+ * A intuição primeira era "quem o pacote EXPORTA fica com o nome", e ela não
21
+ * responde: `publicApi().exports` é um conjunto de NOMES, e os dois candidatos
22
+ * têm o mesmo nome. Escolher pelo que o pacote realmente publica exige o grafo a
23
+ * partir da entrada do manifesto, e essa é a fatia que também entrega os grupos.
24
+ *
25
+ * Então: menos segmentos de caminho vence, e empate resolve alfabeticamente. É
26
+ * pouco, e é honesto - a diferença que importa é que a resposta deixa de mudar
27
+ * conforme a ordem em que o disco devolve os arquivos.
28
+ *
29
+ * E NUNCA POR NOME DE PASTA. Descartar `_legacy` por se chamar `_legacy` é a lei
30
+ * 14 sendo furada: no repo dele `_legacy` tem 178 arquivos e 106 estão VIVOS,
31
+ * montados por rota (medido 07/08). Classificação é por estrutura, nunca por nome.
32
+ *
33
+ * ─────────────────────────────────────────────────────────────────────────
34
+ * O PERDEDOR NÃO ENTRA, E ISSO É DITO
35
+ *
36
+ * Esta fatia não traz as duas receitas - trazer os dois `Card` exige o contexto
37
+ * viajando na chave, que é a fatia dos GRUPOS. O que ela conserta é a perda
38
+ * calada: cada descarte vira uma linha do censo com os dois caminhos, e o
39
+ * relatório os imprime. Uma lacuna declarada é confiança; calada é bug.
40
+ */
41
+ /** Profundidade como o caminho a declara - o número de pastas até o arquivo. */
42
+ const depth = (file) => file.split("/").length;
43
+ /**
44
+ * ESTE ARQUIVO FICA COM ESTE NOME?
45
+ *
46
+ * `true` quando ele detém o nome depois desta chamada - e aí quem chamou pode
47
+ * escrever nos mapas. `false` quando outro arquivo o detém, e a disputa foi
48
+ * registrada em `collisions`.
49
+ *
50
+ * Idempotente para o mesmo arquivo: o laço lê um arquivo uma vez, mas um
51
+ * componente pode ser reivindicado por mais de um caminho de código, e cobrar
52
+ * uma colisão de um arquivo contra si mesmo seria inventar um conflito.
53
+ */
54
+ export function claimName(claims, name, file) {
55
+ const held = claims.by.get(name);
56
+ if (held === undefined) {
57
+ claims.by.set(name, file);
58
+ return true;
59
+ }
60
+ if (held === file)
61
+ return true;
62
+ const incumbentWins = depth(held) < depth(file) ||
63
+ (depth(held) === depth(file) && held.localeCompare(file) <= 0);
64
+ if (incumbentWins) {
65
+ claims.collisions.push({ name, kept: held, dropped: file });
66
+ return false;
67
+ }
68
+ claims.by.set(name, file);
69
+ claims.collisions.push({ name, kept: file, dropped: held });
70
+ return true;
71
+ }
package/dist/stack.js CHANGED
@@ -27,6 +27,15 @@ import { join } from "node:path";
27
27
  * app que usa o sistema, então o manifesto daquele app é evidência tanto quanto
28
28
  * o da raiz.
29
29
  */
30
+ /**
31
+ * O RANGE QUE DIZ "ISTO É DESTE REPOSITÓRIO" - o mesmo prefixo que
32
+ * `frontier-kind.ts` já reconhece como local, escrito aqui uma vez.
33
+ *
34
+ * Sobrescrever o range com este valor é mais verdadeiro do que preservar o que o
35
+ * app digitou: um `*` num monorepo NÃO significa "qualquer versão do npm", e era
36
+ * assim que ele estava sendo lido.
37
+ */
38
+ const LOCAL_RANGE = "workspace:*";
30
39
  export async function resolveDeps(root) {
31
40
  const deps = {};
32
41
  const workspaceDirs = [];
@@ -84,6 +93,47 @@ export async function resolveDeps(root) {
84
93
  }
85
94
  for (const w of workspaceDirs)
86
95
  await readManifest(w);
96
+ /**
97
+ * O NOME DE UM PACOTE DESTE WORKSPACE É LOCAL, QUALQUER QUE SEJA O RANGE - e
98
+ * é a lei 13 se aplicando a si mesma: a ORIGEM decide, não o nome.
99
+ *
100
+ * Medido no monorepo do dono (19/08): `apps/web-dashboard` declara
101
+ * `"@frontend-hub/ui": "*"`, que é como npm e yarn workspaces apontam para o
102
+ * pacote ao lado. `LOCAL_RANGE` em `frontier-kind.ts` reconhece
103
+ * `workspace:|file:|link:|portal:`, então `frontierOf` respondia `opaque` -
104
+ * "de terceiro, se desenha sozinha" - sobre a biblioteca que É o design
105
+ * system dele, em 52 arquivos daquele app. Um monorepo que versiona de
106
+ * verdade (`"^2.1.0"`) caía igual.
107
+ *
108
+ * Quem decidia era o FORMATO DO RANGE, que não é a origem nem o nome: é uma
109
+ * terceira coisa. A origem está aqui - a raiz declara `workspaces`, e um
110
+ * daqueles diretórios tem um `package.json` cujo `name` é aquele
111
+ * especificador. Este laço grava esse fato, e ele SOBRESCREVE o range que o
112
+ * app escreveu, porque um pacote deste repositório não deixa de ser deste
113
+ * repositório por causa de como alguém o apontou.
114
+ *
115
+ * Corrigido aqui e não em `frontier-kind.ts` por dois motivos: a informação já
116
+ * passa por esta função, e aquele arquivo é gêmeo byte-idêntico de
117
+ * `libs/ds-contracts/src/frontier-kind.ts` - mudar a pergunta lá custaria os
118
+ * dois, e a pergunta lá está certa.
119
+ *
120
+ * ENTRA MESMO SEM O APP DECLARAR: alias de tsconfig, import direto ou
121
+ * dependência esquecida - o pacote existe no repositório, e é isso que a lei
122
+ * pergunta.
123
+ */
124
+ for (const w of workspaceDirs) {
125
+ const raw = await readFile(join(w, "package.json"), "utf8").catch(() => null);
126
+ if (!raw)
127
+ continue;
128
+ try {
129
+ const name = JSON.parse(raw).name;
130
+ if (typeof name === "string" && name)
131
+ deps[name] = LOCAL_RANGE;
132
+ }
133
+ catch {
134
+ // manifesto ilegível custa a detecção daquele pacote, nunca a corrida
135
+ }
136
+ }
87
137
  return deps;
88
138
  }
89
139
  /**
@@ -0,0 +1,140 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ const MANIFEST = "package.json";
4
+ const SHEET = /\.(css|scss|sass|less)$/i;
5
+ const DECL = /(^|[\s{;])--[a-zA-Z0-9-]+\s*:/g;
6
+ async function manifest(dir) {
7
+ const raw = await readFile(join(dir, MANIFEST), "utf8").catch(() => null);
8
+ if (!raw)
9
+ return null;
10
+ try {
11
+ return JSON.parse(raw);
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /** Os globs que um manifesto usa para declarar workspaces, nas duas formas reais. */
18
+ function workspaceGlobs(p) {
19
+ const w = p.workspaces;
20
+ const list = Array.isArray(w)
21
+ ? w
22
+ : Array.isArray(w?.packages)
23
+ ? w.packages
24
+ : [];
25
+ return list.filter((g) => typeof g === "string");
26
+ }
27
+ /**
28
+ * Quantas custom properties um pacote declara - o mesmo peso que
29
+ * `siblingProjects` já usa para escolher entre `packages/core` e `packages/ui`.
30
+ *
31
+ * Diretório com ponto e `node_modules` ficam fora pelo mesmo motivo que o `walk`
32
+ * do doctor os ignora: `.next/` guarda CSS COMPILADO, e contá-lo faria o build
33
+ * output votar em onde mora o vocabulário.
34
+ */
35
+ async function weigh(dir) {
36
+ let declarations = 0;
37
+ let files = 0;
38
+ const visit = async (at, depth) => {
39
+ if (depth > 8)
40
+ return;
41
+ for (const e of await readdir(at, { withFileTypes: true }).catch(() => [])) {
42
+ if (e.name.startsWith(".") || e.name === "node_modules")
43
+ continue;
44
+ const full = join(at, e.name);
45
+ if (e.isDirectory()) {
46
+ // Um pacote aninhado fala o próprio sistema - ver `walk`, mesma regra.
47
+ if (await manifest(full))
48
+ continue;
49
+ await visit(full, depth + 1);
50
+ continue;
51
+ }
52
+ if (!SHEET.test(e.name))
53
+ continue;
54
+ const body = await readFile(full, "utf8").catch(() => "");
55
+ const n = (body.match(DECL) ?? []).length;
56
+ if (n > 0) {
57
+ declarations += n;
58
+ files += 1;
59
+ }
60
+ }
61
+ };
62
+ await visit(dir, 0);
63
+ return { declarations, files };
64
+ }
65
+ /**
66
+ * O LUGAR, ou `null` quando não há workspace nenhum acima - e aí não há nada a
67
+ * anunciar, que é o caso da maioria dos projetos.
68
+ *
69
+ * Sobe no máximo quatro níveis, o mesmo teto de `resolveDeps`: cobre todo layout
70
+ * pnpm/npm/yarn sem sair andando pela pasta pessoal de quem roda.
71
+ */
72
+ export async function placeInWorkspace(root) {
73
+ let dir = root;
74
+ let workspaceRoot = null;
75
+ let globs = [];
76
+ for (let up = 0; up < 4; up++) {
77
+ const p = await manifest(dir);
78
+ if (p) {
79
+ const g = workspaceGlobs(p);
80
+ if (g.length > 0) {
81
+ workspaceRoot = dir;
82
+ globs = g;
83
+ break;
84
+ }
85
+ }
86
+ const parent = join(dir, "..");
87
+ if (parent === dir)
88
+ break;
89
+ dir = parent;
90
+ }
91
+ if (!workspaceRoot)
92
+ return null;
93
+ const dirs = [];
94
+ for (const g of globs) {
95
+ if (g.endsWith("/*")) {
96
+ const parent = join(workspaceRoot, g.slice(0, -2));
97
+ for (const e of await readdir(parent, { withFileTypes: true }).catch(() => []))
98
+ if (e.isDirectory())
99
+ dirs.push(join(parent, e.name));
100
+ }
101
+ else if (!g.includes("*")) {
102
+ dirs.push(join(workspaceRoot, g));
103
+ }
104
+ }
105
+ const packages = [];
106
+ const local = new Set();
107
+ const read = [];
108
+ for (const d of dirs) {
109
+ const p = await manifest(d);
110
+ if (!p)
111
+ continue;
112
+ read.push({ p, d });
113
+ if (typeof p.name === "string")
114
+ local.add(p.name);
115
+ }
116
+ for (const { p, d } of read) {
117
+ const { declarations, files } = await weigh(d);
118
+ const deps = {
119
+ ...(p.dependencies ?? {}),
120
+ ...(p.devDependencies ?? {}),
121
+ };
122
+ packages.push({
123
+ rel: relative(workspaceRoot, d).split("\\").join("/"),
124
+ name: typeof p.name === "string" ? p.name : null,
125
+ declarations,
126
+ files,
127
+ dependsOn: Object.keys(deps).filter((k) => local.has(k)),
128
+ });
129
+ }
130
+ if (packages.length === 0)
131
+ return null;
132
+ const rel = relative(workspaceRoot, root).split("\\").join("/");
133
+ const ranked = [...packages].sort((a, b) => b.declarations - a.declarations);
134
+ return {
135
+ workspaceRoot,
136
+ here: rel === "" ? null : rel,
137
+ packages,
138
+ vocabulary: ranked[0]?.declarations > 0 ? ranked[0] : null,
139
+ };
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.251",
3
+ "version": "0.16.253",
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": {