synthesisui 0.16.253 → 0.16.255

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.
@@ -154,7 +154,20 @@ export async function add(slug, opts) {
154
154
  * censo (um `add` puro, sem import antes), o que o lock já dizia é preservado.
155
155
  */
156
156
  const measured = await censusScope(projectRoot);
157
- const scope = measured.system ?? prev?.scope ?? null;
157
+ /**
158
+ * OS DOIS CAMPOS: `scopes` com tudo e `scope` com o primeiro.
159
+ *
160
+ * O lock é o arquivo que um time COMMITA, e é o que faz um clone fresco saber de
161
+ * onde o sistema foi medido sem ida à rede. Guardar só o primeiro escopo ali
162
+ * fazia o `sync` de um clone novo remedir metade de um sistema de dois escopos.
163
+ * Um CLI anterior lê `scope` e segue funcionando.
164
+ */
165
+ const systems = measured.systems.length > 0
166
+ ? measured.systems
167
+ : measured.system
168
+ ? [measured.system]
169
+ : (prev?.scopes ?? (prev?.scope ? [prev.scope] : []));
170
+ const scope = systems[0] ?? null;
158
171
  const usage = measured.usage.length > 0 ? measured.usage : (prev?.usage ?? []);
159
172
  /**
160
173
  * `fetchedAt` SÓ ANDA QUANDO ALGO ATERROU - e a alternativa sujava o git de um time inteiro.
@@ -177,6 +190,7 @@ export async function add(slug, opts) {
177
190
  ...(payload.compiler != null ? { compiler: payload.compiler } : {}),
178
191
  ...(payload.rulesStamp ? { rules: payload.rulesStamp } : {}),
179
192
  ...(scope ? { scope } : {}),
193
+ ...(systems.length > 0 ? { scopes: systems } : {}),
180
194
  ...(usage.length > 0 ? { usage } : {}),
181
195
  };
182
196
  const landed = (() => {
@@ -42,7 +42,21 @@ import { resolveDeps } from "../stack.js";
42
42
  /** Files that compose components to SHOW them, not to ship them. */
43
43
  const IS_ASIDE = /(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/;
44
44
  const EXTS = [".tsx", ".ts", ".jsx", ".js", ".css", ".scss", ".vue", ".svelte"];
45
- const SKIP = new Set([
45
+ /**
46
+ * EXPORTADO porque quem mede o repositório fora deste arquivo tem que pular as
47
+ * MESMAS pastas.
48
+ *
49
+ * `workspace-place.ts` contava as declarações de cada pacote com a sua própria
50
+ * regra (dotdir + node_modules) e somava `packages/ui/dist/index.css`: 496
51
+ * declarações de bundle contra 97 de fonte no repo do dono, medido em 19/08. O
52
+ * número que decidia qual pasta recomendar era cinco sextos build output - e o
53
+ * censo, que usa este SKIP, nunca leu nada daquilo.
54
+ *
55
+ * Duas listas de pastas a ignorar é a mesma classe de divergência que
56
+ * `loadSystem` existe para matar: nenhuma delas está errada isolada, e juntas
57
+ * dizem números diferentes sobre o mesmo repositório.
58
+ */
59
+ export const SKIP = new Set([
46
60
  "node_modules",
47
61
  ".next",
48
62
  ".git",
@@ -551,7 +565,7 @@ export async function doctor(opts) {
551
565
  * lista de prioridades tirada de um app consumidor num repo de 2787 arquivos (dono, 06/08).
552
566
  */
553
567
  const measured = (opts.scopes ?? []).length > 0
554
- ? { system: null, usage: [], from: "none" }
568
+ ? { system: null, systems: [], usage: [], from: "none" }
555
569
  : await measuredScope(root);
556
570
  const relScopes = (opts.scopes ?? []).length > 0
557
571
  ? opts.scopes
@@ -35,6 +35,7 @@ import { definitionSpan, parseClass, readInlineStyle, rootClasses, rootTag, tran
35
35
  import { transcribeVariants } from "../doctor/variant-read.js";
36
36
  import { frontierKind, packageRoot } from "../frontier-kind.js";
37
37
  import { withLibraryStructure } from "../library-structure.js";
38
+ import { mergeCensus } from "../merge-census.js";
38
39
  import { claimName } from "../name-claim.js";
39
40
  import { body, paint, section } from "../output.js";
40
41
  import { phase, startProgress } from "../progress.js";
@@ -1147,13 +1148,39 @@ export async function takeCensus(root, opts) {
1147
1148
  const edges = new Map();
1148
1149
  /** Por raiz: `@/` é uma pasta diferente em cada app - ver `reachabilityOf`. */
1149
1150
  const graphRoots = [];
1151
+ /**
1152
+ * A DISTÂNCIA, contada onde os arquivos já estão abertos - ver `Census.adoption`.
1153
+ *
1154
+ * `scopePkg` é o nome do pacote do escopo, que é como um consumidor o importa.
1155
+ * Um arquivo conta quando ele NOMEIA esse especificador, e a régua é o texto do
1156
+ * import: um alias de tsconfig que aponta para a mesma pasta contaria como
1157
+ * "não usa", e essa é a diferença entre um número menor e um número inventado.
1158
+ */
1159
+ const adoption = new Map();
1150
1160
  for (const u of opts?.usage ?? []) {
1151
1161
  const uInternal = await internalSpecifiers(u.path);
1152
1162
  graphRoots.push({ path: u.path, aliases: await aliasesOf(u.path) });
1163
+ const tally = adoption.get(u.label) ?? { files: 0, using: 0 };
1164
+ adoption.set(u.label, tally);
1153
1165
  for await (const file of walk(u.path)) {
1154
1166
  const src = await readFile(file, "utf8").catch(() => "");
1155
1167
  if (!src)
1156
1168
  continue;
1169
+ /**
1170
+ * O DENOMINADOR SÓ CONTA QUEM PINTA.
1171
+ *
1172
+ * A primeira versão contava todo `.ts` - hooks, tipos, clientes http - e o
1173
+ * app do dono saiu como 105 de 1578 (7%), quando 105 de 356 arquivos de
1174
+ * marcação é 29%. Um arquivo de tipos não deixa de usar o design system:
1175
+ * ele nunca teve como. Diluir o denominador com ele faz a migração parecer
1176
+ * mais atrasada do que é, e um número pessimista é tão falso quanto um
1177
+ * otimista.
1178
+ */
1179
+ if (/\.(tsx|jsx|vue|svelte)$/i.test(file)) {
1180
+ tally.files += 1;
1181
+ if (scopePkg && src.includes(scopePkg))
1182
+ tally.using += 1;
1183
+ }
1157
1184
  edges.set(file, edgesIn(src));
1158
1185
  const rel = join(u.label, relative(u.path, file));
1159
1186
  if (/(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/.test(rel)) {
@@ -1914,6 +1941,18 @@ export async function takeCensus(root, opts) {
1914
1941
  * só aparece quando há algo a dizer não muda nada para quem já lê o censo.
1915
1942
  */
1916
1943
  ...(claims.collisions.length > 0 ? { collisions: claims.collisions } : {}),
1944
+ ...(scopePkg && adoption.size > 0
1945
+ ? {
1946
+ adoption: {
1947
+ specifier: scopePkg,
1948
+ consumers: [...adoption.entries()].map(([label, t]) => ({
1949
+ label,
1950
+ files: t.files,
1951
+ using: t.using,
1952
+ })),
1953
+ },
1954
+ }
1955
+ : {}),
1917
1956
  ...(conventions.length > 0 ? { conventions } : {}),
1918
1957
  classStyle,
1919
1958
  ...(Object.keys(looks).length > 0 ? { looks } : {}),
@@ -2029,6 +2068,8 @@ export async function takeCensus(root, opts) {
2029
2068
  named: d.named,
2030
2069
  tokenUses: d.tokenUses,
2031
2070
  coverage: d.coverage,
2071
+ ownUses: d.ownUses,
2072
+ phantomUses: d.phantomUses,
2032
2073
  },
2033
2074
  };
2034
2075
  }
@@ -2089,9 +2130,73 @@ async function summarize(c, root, scope) {
2089
2130
  }
2090
2131
  }
2091
2132
  sayConventions(c);
2133
+ sayScopes(c);
2134
+ sayAdoption(c);
2092
2135
  sayCollisions(c);
2093
2136
  await sayBrokenRefs(c, root, scope);
2094
2137
  }
2138
+ /**
2139
+ * OS ESCOPOS QUE VIRARAM UM SISTEMA, e o que cada um perdeu no empate.
2140
+ *
2141
+ * `--scope` aceita mais de um, e a ordem é uma declaração: o primeiro vence. Um
2142
+ * token que os dois declaram diferente não é erro de ninguém - é divergência, e
2143
+ * a lei do dono (§8.6) é que a plataforma não decide sozinha. Ela mostra os
2144
+ * dois, diz quem é a fonte declarada, e propõe alinhar o outro lado.
2145
+ *
2146
+ * Medido no monorepo dele: `packages/ui` e `apps/web-dashboard/src` declaram os
2147
+ * mesmos três `--gradient-gray-*` com valores opostos - claros na biblioteca,
2148
+ * escuros no app. Nenhum dos dois está errado, e um deles ia desaparecer calado.
2149
+ */
2150
+ function sayScopes(c) {
2151
+ const scopes = c.scopes ?? [];
2152
+ if (scopes.length < 2)
2153
+ return;
2154
+ const conflicts = c.scopeConflicts ?? [];
2155
+ console.log("");
2156
+ console.log(section("Two folders, one system"));
2157
+ console.log(body(`Measured ${scopes.length} scopes and fused them: ${scopes.map((x, i) => (i === 0 ? paint.strong(x) : x)).join(" · ")}. The first one is the authority - it wins a tie, and nothing from the others is dropped without a line.`));
2158
+ if (conflicts.length === 0)
2159
+ return;
2160
+ console.log("");
2161
+ console.log(body(`${conflicts.length} token${conflicts.length === 1 ? "" : "s"} ${conflicts.length === 1 ? "is" : "are"} declared differently in two of them:`));
2162
+ console.log("");
2163
+ for (const x of conflicts.slice(0, 5)) {
2164
+ console.log(body(`${paint.strong(x.name)}\n ${paint.faint("kept")} ${x.kept.value} ${paint.faint(`(${x.kept.scope})`)}\n ${paint.faint("also")} ${x.lost.value} ${paint.faint(`(${x.lost.scope})`)}`));
2165
+ }
2166
+ if (conflicts.length > 5)
2167
+ console.log(body(paint.faint(`(${conflicts.length - 5} more)`)));
2168
+ console.log("");
2169
+ console.log(body(paint.faint("Neither value is wrong - one of them is a different scheme, or a fork that drifted. The system carries the first; the screen can show both.")));
2170
+ }
2171
+ /**
2172
+ * QUANTO DA MIGRAÇÃO JÁ ACONTECEU - o número que faltava.
2173
+ *
2174
+ * O `intent` já dizia que alguém está migrando design para o pacote
2175
+ * compartilhado (`init --intent migrate`, versionado em `_synthesisui/config.json`
2176
+ * com a data). O que nenhuma superfície dizia é QUANTO já chegou. Medido no
2177
+ * monorepo do dono: 103 de 356 arquivos do `web-dashboard` importam
2178
+ * `@frontend-hub/ui`, e o `web-review` não o conhece.
2179
+ *
2180
+ * É a diferença entre medir estado e medir progresso: um relatório de estado é
2181
+ * igual em janeiro e em junho; este muda a cada versão.
2182
+ */
2183
+ function sayAdoption(c) {
2184
+ const a = c.adoption;
2185
+ if (!a || a.consumers.length === 0)
2186
+ return;
2187
+ const total = a.consumers.reduce((n, x) => n + x.files, 0);
2188
+ const using = a.consumers.reduce((n, x) => n + x.using, 0);
2189
+ console.log("");
2190
+ console.log(section("How far the system already reaches"));
2191
+ console.log(body(`${using} of ${total} source files across the folders you named import ${paint.strong(a.specifier)}. Everything else in them is still painting on its own.`));
2192
+ console.log("");
2193
+ for (const x of a.consumers) {
2194
+ const pct = x.files > 0 ? Math.round((x.using / x.files) * 100) : 0;
2195
+ console.log(body(`${paint.strong(x.label.padEnd(24))} ${String(x.using).padStart(4)} of ${String(x.files).padStart(4)} files ${paint.faint(`· ${pct}%`)}${x.using === 0 ? paint.faint(" - does not know the system yet") : ""}`));
2196
+ }
2197
+ console.log("");
2198
+ console.log(body(paint.faint("This is the number a migration is measured by, and it is the one that moves. Run the import again after a batch and the difference is the progress.")));
2199
+ }
2095
2200
  /**
2096
2201
  * DOIS ARQUIVOS, UM NOME - dito, com os dois caminhos.
2097
2202
  *
@@ -2107,16 +2212,35 @@ function sayCollisions(c) {
2107
2212
  const all = c.collisions ?? [];
2108
2213
  if (all.length === 0)
2109
2214
  return;
2215
+ /**
2216
+ * DUAS REGRAS, DUAS FRASES. Dentro de um escopo vence quem está mais perto da
2217
+ * raiz; entre escopos vence o primeiro `--scope`. Explicar um empate com a
2218
+ * regra do outro é descrever algo que não aconteceu.
2219
+ */
2220
+ const crossScope = all.filter((x) => x.by === "scope");
2221
+ const inScope = all.filter((x) => x.by !== "scope");
2110
2222
  const names = new Set(all.map((x) => x.name));
2111
2223
  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}`));
2224
+ console.log(section("Two places, one name"));
2225
+ 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.`));
2226
+ if (crossScope.length > 0) {
2227
+ console.log("");
2228
+ console.log(body(`${crossScope.length} of them in two different scopes - the FIRST --scope kept the name:`));
2229
+ console.log("");
2230
+ for (const x of crossScope.slice(0, 6))
2231
+ console.log(body(`${paint.strong(x.name.padEnd(22))} ${paint.faint("kept in")} ${x.kept} ${paint.faint("· also in")} ${x.dropped}`));
2232
+ if (crossScope.length > 6)
2233
+ console.log(body(paint.faint(`(${crossScope.length - 6} more)`)));
2234
+ }
2235
+ if (inScope.length > 0) {
2236
+ console.log("");
2237
+ console.log(body(`${inScope.length} inside a single scope - the file closest to its root kept the name, never the order the disk happened to return:`));
2238
+ console.log("");
2239
+ for (const x of inScope.slice(0, 6))
2240
+ console.log(body(`${paint.strong(x.name)}\n ${paint.faint("kept")} ${x.kept}\n ${paint.faint("dropped")} ${x.dropped}`));
2241
+ if (inScope.length > 6)
2242
+ console.log(body(paint.faint(`(${inScope.length - 6} more)`)));
2117
2243
  }
2118
- if (all.length > 6)
2119
- console.log(body(paint.faint(`(${all.length - 6} more)`)));
2120
2244
  console.log("");
2121
2245
  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.")));
2122
2246
  }
@@ -2474,8 +2598,21 @@ async function sayAboutPlace(root, scope) {
2474
2598
  console.log(section("This folder is one package of a workspace"));
2475
2599
  console.log(body(`${paint.strong(place.here)} is one of ${place.packages.length} packages under ${paint.strong(place.workspaceRoot)}.`));
2476
2600
  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.")));
2601
+ console.log(body(
2602
+ /**
2603
+ * A FRASE SEGUE O CRITÉRIO, e o critério é estrutural.
2604
+ *
2605
+ * Enquanto a contagem incluía `dist`, dizer "o vocabulário não está aqui"
2606
+ * fechava com os números: 764 contra 114. Com o build output fora, a
2607
+ * biblioteca declara 97 e o APP declara 114 - a frase antiga passaria a
2608
+ * contradizer os dois números que ela mesma imprime.
2609
+ *
2610
+ * O que faz `packages/ui` ser a fonte de verdade não é volume: é os outros
2611
+ * pacotes o importarem. Dizer isso é dizer a razão verdadeira, e ela
2612
+ * sobrevive ao caso em que o app declara mais.
2613
+ */
2614
+ `${paint.strong(vocab.rel)} is what the other ${vocab.importedBy} package${vocab.importedBy === 1 ? "" : "s"} here import${vocab.importedBy === 1 ? "s" : ""} - that is the shared vocabulary, ${vocab.declarations} token${vocab.declarations === 1 ? "" : "s"} across ${vocab.files} file${vocab.files === 1 ? "" : "s"}. This package declares ${mine?.declarations ?? 0} of its own across ${mine?.files ?? 0}, and those are this app's.`));
2615
+ console.log(body(paint.faint("A system measured from the app is one consumer's set of choices; the shared vocabulary is where the library declares it.")));
2479
2616
  console.log("");
2480
2617
  console.log(body("The system is one place, and the evidence is another:"));
2481
2618
  console.log("");
@@ -3158,8 +3295,18 @@ export async function runImport(opts) {
3158
3295
  .trim()
3159
3296
  .replace(/^\.\/+/, "")
3160
3297
  .replace(/\/+$/, "");
3161
- const scope = opts.scope ? tidy(opts.scope) : undefined;
3162
- const readFrom = scope ? join(root, scope) : root;
3298
+ /**
3299
+ * OS ESCOPOS, na ordem em que a pessoa os escreveu - e `scope` é o primeiro.
3300
+ *
3301
+ * O parser de flags já acumula uma flag repetida (`--usage` usa isso desde
3302
+ * 01/08), então `--scope packages/ui --scope packages/charts` chega aqui como
3303
+ * array sem nenhuma mudança de gramática.
3304
+ */
3305
+ const scopes = (Array.isArray(opts.scope) ? opts.scope : opts.scope ? [opts.scope] : [])
3306
+ .map(tidy)
3307
+ .filter(Boolean)
3308
+ .filter((v, i, all) => all.indexOf(v) === i);
3309
+ const scope = scopes[0];
3163
3310
  /**
3164
3311
  * THE SECOND ROLE. `--scope` is the SYSTEM - one place, and the only source of
3165
3312
  * the palette, the scales and the convention. `--usage` is the EVIDENCE - as
@@ -3234,13 +3381,33 @@ export async function runImport(opts) {
3234
3381
  */
3235
3382
  phase(1, 3, "Measuring the repository");
3236
3383
  console.log(section("Reading your project"));
3237
- census = await takeCensus(readFrom, {
3238
- ...(usage.length > 0 ? { usage } : {}),
3239
- ...(scope ? { scopeLabel: scope } : {}),
3240
- ...(opts.cli ? { cli: opts.cli } : {}),
3241
- });
3242
- if (scope)
3243
- census.scope = scope;
3384
+ /**
3385
+ * UMA MEDIÇÃO POR ESCOPO, E DEPOIS A FUSÃO.
3386
+ *
3387
+ * `takeCensus` recebe UM `root` em cinco pontos estruturais que são por
3388
+ * PACOTE e não por sistema (`harvestOwnCss`, `internalSpecifiers`,
3389
+ * `publicApi`, o `walk`, `aliasesOf`), então ensiná-la a medir N raízes
3390
+ * seria refatorar a função de 1855 linhas que o corpus dourado protege.
3391
+ * Medir N vezes e fundir troca esse risco por uma função pura - e as regras
3392
+ * de empate ficam num arquivo em vez de espalhadas por um laço.
3393
+ *
3394
+ * A EVIDÊNCIA VIAJA COM O PRIMEIRO: `--usage` doa contagem e lei, e somar a
3395
+ * mesma evidência N vezes multiplicaria os números que decidem o que é
3396
+ * hábito. Ela entra uma vez, na medição do escopo que declara.
3397
+ */
3398
+ const measured = [];
3399
+ for (const [i, one] of (scopes.length > 0 ? scopes : [""]).entries()) {
3400
+ const at = one ? join(root, one) : root;
3401
+ const c = await takeCensus(at, {
3402
+ ...(i === 0 && usage.length > 0 ? { usage } : {}),
3403
+ ...(one ? { scopeLabel: one } : {}),
3404
+ ...(opts.cli ? { cli: opts.cli } : {}),
3405
+ });
3406
+ if (one)
3407
+ c.scope = one;
3408
+ measured.push(c);
3409
+ }
3410
+ census = mergeCensus(measured);
3244
3411
  }
3245
3412
  /**
3246
3413
  * DERIVE THE ANATOMY ON THE MEASURING PATH TOO.
@@ -3270,8 +3437,30 @@ export async function runImport(opts) {
3270
3437
  * `sayAboutPlace` faz as duas perguntas na ordem: onde este comando está, e
3271
3438
  * onde as declarações estão. Ele só fala quando as respostas divergem.
3272
3439
  */
3273
- if (!opts.census)
3440
+ /**
3441
+ * OS CANDIDATOS ENTRAM NO CENSO, e é o mesmo `placeInWorkspace` que a mensagem
3442
+ * de lugar já usa - uma medição, dois consumidores.
3443
+ *
3444
+ * Vive aqui e não dentro de `takeCensus` porque a pergunta é sobre o
3445
+ * WORKSPACE, e `takeCensus` mede um escopo: chamá-la de lá faria cada escopo
3446
+ * medido recontar os mesmos cinco pacotes.
3447
+ */
3448
+ if (!opts.census) {
3449
+ const place = await placeInWorkspace(root).catch(() => null);
3450
+ if (place)
3451
+ census.workspace = {
3452
+ root: place.workspaceRoot,
3453
+ here: place.here,
3454
+ packages: place.packages.map((p) => ({
3455
+ rel: p.rel,
3456
+ name: p.name,
3457
+ declarations: p.declarations,
3458
+ files: p.files,
3459
+ importedBy: p.importedBy,
3460
+ })),
3461
+ };
3274
3462
  await sayAboutPlace(root, scope);
3463
+ }
3275
3464
  // A census handed to us is written BACK TO ITSELF; one we took lands at the
3276
3465
  // ROOT, whatever it measured. Everything else in `_synthesisui/` is anchored
3277
3466
  // there - config, `ds/<slug>/`, the hook's marker - and the census was the
@@ -1042,6 +1042,23 @@ async function describeComponent(root, name) {
1042
1042
  if ((recipe.companions ?? []).length > 0) {
1043
1043
  out.push("", `Goes with: ${(recipe.companions ?? []).join(", ")} - measured pairings; call describe_component on one before composing them together.`);
1044
1044
  }
1045
+ /**
1046
+ * ONDE ELE MORA, E COMO CHEGAR NELE - a linha que decide se o agente importa
1047
+ * ou reescreve.
1048
+ *
1049
+ * `api.from` é o especificador do pacote que o EXPORTA (a lei 13 já o mediu), e
1050
+ * ele só existe para um componente de biblioteca. Quando há um, importar é a
1051
+ * resposta e escrever de novo é duplicar. Quando não há, o caminho é o que
1052
+ * localiza o arquivo - e num sistema de dois escopos ele é a única coisa que
1053
+ * diz de qual dos dois este componente é.
1054
+ */
1055
+ if (recipe.source?.file || recipe.api?.from) {
1056
+ const where = recipe.source?.file;
1057
+ const pkg = recipe.api?.from;
1058
+ out.push("", pkg
1059
+ ? `Lives in: ${where ?? pkg} - exported by ${pkg}. Import it from there; writing it again duplicates a component this system already owns.`
1060
+ : `Lives in: ${where}${recipe.source?.scope ? ` (scope ${recipe.source.scope})` : ""} - local to that folder, and not exported by a package.`);
1061
+ }
1045
1062
  if (recipe.usageStats && recipe.usageStats.count > 0) {
1046
1063
  const u = recipe.usageStats;
1047
1064
  out.push("", `In use: ${u.count} instance${u.count === 1 ? "" : "s"} across ${u.files} file${u.files === 1 ? "" : "s"}${u.projects?.length ? ` in ${u.projects.join(", ")}` : ""}.`);
@@ -6,10 +6,11 @@ import { markSent, readEvents } from "../doctor/ledger.js";
6
6
  import { checkableName, closeRequest, readRequests, verifyAndCloseRequests, } from "../doctor/requests.js";
7
7
  import { describeDelta, fingerprintReadings, readSyncMark, writeSyncMark, } from "../last-sync.js";
8
8
  import { measuredScope, rememberScope } from "../measured-scope.js";
9
+ import { mergeCensus } from "../merge-census.js";
9
10
  import { body, paint, section, snippet } from "../output.js";
10
11
  import { repoStateOf } from "../repo-state.js";
11
12
  import { reportWhatIsLeft } from "./align.js";
12
- import { resolveReadParts, siblingProjects, takeCensus } from "./import.js";
13
+ import { resolveReadParts, siblingProjects, takeCensus, } from "./import.js";
13
14
  /**
14
15
  * What each decision means ON THIS MACHINE - the card decides, the sync
15
16
  * enacts. The web never touches the local file; this line is printed right
@@ -247,7 +248,25 @@ export async function remeasure(args) {
247
248
  * O servidor continua valendo como segunda fonte: um clone fresco tem o `.lock` e não tem censo.
248
249
  */
249
250
  const local = await measuredScope(root);
250
- let scope = local.system ?? stored.scope ?? null;
251
+ /**
252
+ * TODOS os escopos do sistema, não só o primeiro.
253
+ *
254
+ * Esta linha lia `local.system` - singular - e um sistema importado de dois
255
+ * escopos remedia só um deles: 351 componentes nasciam e 36 eram remedidos. Os
256
+ * outros 315 não eram apagados (`presenceOf` os conta como fora do escopo
257
+ * medido, de propósito) e também nunca mais eram atualizados - uma feature que
258
+ * emudece no dia seguinte.
259
+ *
260
+ * A queda para `scope` mantém todo sistema anterior lendo como antes.
261
+ */
262
+ let scopes = local.systems.length > 0
263
+ ? local.systems
264
+ : local.system
265
+ ? [local.system]
266
+ : stored.scope
267
+ ? [stored.scope]
268
+ : [];
269
+ let scope = scopes[0] ?? null;
251
270
  let usage = local.usage.length > 0 ? local.usage : (stored.usage ?? []);
252
271
  /**
253
272
  * E QUANDO NINGUÉM SABE, ESTE COMANDO PERGUNTA - uma vez, e grava a resposta.
@@ -263,26 +282,43 @@ export async function remeasure(args) {
263
282
  const asked = await askForScope(root);
264
283
  if (asked) {
265
284
  scope = asked.scope;
285
+ scopes = [asked.scope];
266
286
  if (asked.usage.length > 0)
267
287
  usage = asked.usage;
268
- await rememberScope(root, slug, scope, usage);
288
+ await rememberScope(root, slug, scopes, usage);
269
289
  }
270
290
  }
271
291
  console.log(section("Measuring your repository again"));
272
- console.log(body(paint.faint(`${scope ? `reading ${scope}` : "the whole repo"}${local.system && local.system !== stored.scope
292
+ console.log(body(paint.faint(`${scopes.length > 1 ? `reading ${scopes.join(" + ")}` : scope ? `reading ${scope}` : "the whole repo"}${local.system && local.system !== stored.scope
273
293
  ? " · read from _synthesisui/census.json, where the last measurement recorded it"
274
294
  : ""} · what you already wrote about your app travels unchanged`)));
275
- const census = await takeCensus(scope ? join(root, scope) : root, {
276
- ...(scope ? { scopeLabel: scope } : {}),
277
- ...(args.cli ? { cli: args.cli } : {}),
278
- /** Uma RE-medição conta o que MUDOU; o relatório inteiro fica no `--full` - ver `say`. */
279
- ...(args.full ? {} : { quiet: true }),
280
- /** O `--usage` é EVIDÊNCIA e leva rótulo: sem ele o censo não sabe de que app o número veio. */
281
- usage: usage.map((u) => ({
282
- path: join(root, u),
283
- label: u,
284
- })),
285
- }).catch(() => null);
295
+ /**
296
+ * UMA MEDIÇÃO POR ESCOPO, FUNDIDAS - o mesmo desenho do `import`.
297
+ *
298
+ * `takeCensus` recebe um `root` em cinco pontos que são por PACOTE, então medir
299
+ * N raízes de dentro dela seria refatorar a função que o corpus dourado guarda.
300
+ * A evidência (`--usage`) entra UMA vez, com o primeiro escopo: somá-la N vezes
301
+ * multiplicaria as contagens que decidem o que é hábito.
302
+ */
303
+ const measuredList = [];
304
+ for (const [i, one] of (scopes.length > 0 ? scopes : [""]).entries()) {
305
+ const c = await takeCensus(one ? join(root, one) : root, {
306
+ ...(one ? { scopeLabel: one } : {}),
307
+ ...(args.cli ? { cli: args.cli } : {}),
308
+ /** Uma RE-medição conta o que MUDOU; o relatório inteiro fica no `--full` - ver `say`. */
309
+ ...(args.full ? {} : { quiet: true }),
310
+ /** O `--usage` é EVIDÊNCIA e leva rótulo: sem ele o censo não sabe de que app o número veio. */
311
+ usage: i === 0 ? usage.map((u) => ({ path: join(root, u), label: u })) : [],
312
+ }).catch(() => null);
313
+ if (!c)
314
+ break;
315
+ if (one)
316
+ c.scope = one;
317
+ measuredList.push(c);
318
+ }
319
+ const census = measuredList.length === (scopes.length > 0 ? scopes.length : 1)
320
+ ? mergeCensus(measuredList)
321
+ : null;
286
322
  if (!census) {
287
323
  console.log(body("The measurement failed, so nothing was sent."));
288
324
  return;
package/dist/index.js CHANGED
@@ -89,7 +89,8 @@ Options:
89
89
  --version <n> install a specific version (default: latest)
90
90
  --ds <slug> init: bring this DS in right away · generate: target DS (default: installed)
91
91
  --name <name> preferred component name for generate
92
- --scope <path> import: the SYSTEM - tokens, components, convention. One folder.
92
+ --scope <path> import: the SYSTEM - tokens, components, convention. Repeatable;
93
+ the first one wins a tie.
93
94
  --usage <path> import: the EVIDENCE - counts, chosen values, laws. Repeatable.
94
95
  --scheme <s> dark|light - which end of your ladder is the default
95
96
  --target <t> template/init target: next | general (default: next)
@@ -160,7 +161,16 @@ async function main() {
160
161
  // Only these two words. Anything else is a typo that would silently
161
162
  // invert a system, so it falls through to being measured and asked.
162
163
  // WHAT to read. `--dir` stays WHERE the project is, in every command.
163
- scope: typeof flags.scope === "string" ? flags.scope : undefined,
164
+ /**
165
+ * `--scope` REPETÍVEL: um sistema pode viver em dois pacotes, e o parser
166
+ * já acumula flag repetida (`--usage` usa isso). A ordem é a que a pessoa
167
+ * escreveu, e ela decide os empates - ver `merge-census.ts`.
168
+ */
169
+ scope: Array.isArray(flags.scope)
170
+ ? flags.scope
171
+ : typeof flags.scope === "string"
172
+ ? flags.scope
173
+ : undefined,
164
174
  // WHERE THE EVIDENCE IS. Repeatable, and comma-separated works too - a
165
175
  // person typing one path with a comma in it means two paths.
166
176
  usage: [flags.usage]
@@ -100,7 +100,7 @@
100
100
  *
101
101
  * Quem instalou antes desta versão tem o stub que estanca: o `upgrade`/`connect` é o que alcança.
102
102
  */
103
- export const MATERIALISER_SINCE = "0.16.241";
103
+ export const MATERIALISER_SINCE = "0.16.255";
104
104
  /**
105
105
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
106
106
  *
@@ -1,6 +1,11 @@
1
1
  import { readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
- const EMPTY = { system: null, usage: [], from: "none" };
3
+ const EMPTY = {
4
+ system: null,
5
+ systems: [],
6
+ usage: [],
7
+ from: "none",
8
+ };
4
9
  /**
5
10
  * O ESCOPO NO PONTEIRO DO SISTEMA, que é onde ele não se perde.
6
11
  *
@@ -27,22 +32,42 @@ async function scopeInLock(root) {
27
32
  catch {
28
33
  continue;
29
34
  }
30
- const scope = typeof lock.scope === "string" && lock.scope.trim()
31
- ? lock.scope.trim()
32
- : null;
35
+ /**
36
+ * `scopes` PRIMEIRO, `scope` como queda - e nunca o contrário.
37
+ *
38
+ * Um lock escrito antes desta versão só tem `scope`, e ele continua sendo lido
39
+ * exatamente como antes. Um lock novo tem os dois, e a lista é a verdade
40
+ * completa: ler o singular ali daria um sistema de um escopo onde há dois.
41
+ */
42
+ const listed = Array.isArray(lock.scopes)
43
+ ? lock.scopes.filter((x) => typeof x === "string" && !!x.trim())
44
+ : [];
45
+ const scope = listed[0] ??
46
+ (typeof lock.scope === "string" && lock.scope.trim()
47
+ ? lock.scope.trim()
48
+ : null);
49
+ const declared = listed.length > 0 ? listed : scope ? [scope] : [];
33
50
  const usage = Array.isArray(lock.usage)
34
51
  ? lock.usage.filter((u) => typeof u === "string" && !!u.trim())
35
52
  : [];
36
- if (!scope && usage.length === 0)
53
+ if (declared.length === 0 && usage.length === 0)
37
54
  continue;
38
- const system = scope && (await present(root, scope)) ? scope : null;
55
+ const systems = [];
56
+ for (const d of declared)
57
+ if (await present(root, d))
58
+ systems.push(d);
39
59
  const kept = [];
40
60
  for (const u of usage)
41
61
  if (await present(root, u))
42
62
  kept.push(u);
43
- if (!system && kept.length === 0)
63
+ if (systems.length === 0 && kept.length === 0)
44
64
  continue;
45
- return { system, usage: kept, from: "lock" };
65
+ return {
66
+ system: systems[0] ?? null,
67
+ systems,
68
+ usage: kept,
69
+ from: "lock",
70
+ };
46
71
  }
47
72
  return null;
48
73
  }
@@ -81,20 +106,29 @@ export async function censusScope(root) {
81
106
  catch {
82
107
  return EMPTY;
83
108
  }
84
- const scope = typeof parsed.scope === "string" && parsed.scope.trim()
85
- ? parsed.scope.trim()
86
- : null;
109
+ /** `scopes` primeiro, `scope` como queda - ver `scopeInLock`, mesma regra. */
110
+ const listed = Array.isArray(parsed.scopes)
111
+ ? parsed.scopes.filter((x) => typeof x === "string" && !!x.trim())
112
+ : [];
113
+ const scope = listed[0] ??
114
+ (typeof parsed.scope === "string" && parsed.scope.trim()
115
+ ? parsed.scope.trim()
116
+ : null);
117
+ const declared = listed.length > 0 ? listed : scope ? [scope] : [];
87
118
  const usage = Array.isArray(parsed.usage)
88
119
  ? parsed.usage.filter((u) => typeof u === "string" && !!u.trim())
89
120
  : [];
90
- const system = scope && (await present(root, scope)) ? scope : null;
121
+ const systems = [];
122
+ for (const d of declared)
123
+ if (await present(root, d))
124
+ systems.push(d);
91
125
  const kept = [];
92
126
  for (const u of usage)
93
127
  if (await present(root, u))
94
128
  kept.push(u);
95
- if (!system && kept.length === 0)
129
+ if (systems.length === 0 && kept.length === 0)
96
130
  return EMPTY;
97
- return { system, usage: kept, from: "census" };
131
+ return { system: systems[0] ?? null, systems, usage: kept, from: "census" };
98
132
  }
99
133
  /**
100
134
  * As pastas a LER, na ordem em que importam - o sistema primeiro.
@@ -103,7 +137,12 @@ export async function censusScope(root) {
103
137
  * anotações no CI, decide o que chega ao PR.
104
138
  */
105
139
  export function scopePaths(m) {
106
- return [...(m.system ? [m.system] : []), ...m.usage];
140
+ /**
141
+ * TODOS os sistemas, não só o primeiro - senão `absorb` e `summary` veem meia
142
+ * biblioteca num sistema importado de dois escopos.
143
+ */
144
+ const systems = m.systems.length > 0 ? m.systems : m.system ? [m.system] : [];
145
+ return [...systems, ...m.usage];
107
146
  }
108
147
  /** Como dizer o que foi lido. Uma leitura que não declara o universo é um número sem denominador. */
109
148
  export function describeScope(m) {
@@ -131,7 +170,18 @@ export function describeScope(m) {
131
170
  *
132
171
  * Silencioso quando não há lock: não é erro, é um repo sem sistema instalado.
133
172
  */
134
- export async function rememberScope(root, slug, scope, usage) {
173
+ export async function rememberScope(root, slug,
174
+ /**
175
+ * OS ESCOPOS, na ordem em que a pessoa os pediu - o primeiro é a autoridade.
176
+ *
177
+ * Aceita a lista e grava OS DOIS campos: `scopes` com tudo e `scope` com o
178
+ * primeiro. Um CLI anterior lê `scope` e continua funcionando; um lock antigo
179
+ * lido por este CLI cai no singular. É a compatibilidade nos dois sentidos, e
180
+ * ela custa uma linha.
181
+ */
182
+ scopes, usage) {
183
+ const list = (Array.isArray(scopes) ? scopes : scopes ? [scopes] : []).filter(Boolean);
184
+ const scope = list[0] ?? null;
135
185
  const path = join(root, "_synthesisui", "ds", slug, ".lock");
136
186
  const raw = await readFile(path, "utf8").catch(() => null);
137
187
  if (!raw)
@@ -146,6 +196,7 @@ export async function rememberScope(root, slug, scope, usage) {
146
196
  await writeFile(path, `${JSON.stringify({
147
197
  ...lock,
148
198
  ...(scope ? { scope } : {}),
199
+ ...(list.length > 0 ? { scopes: list } : {}),
149
200
  ...(usage.length > 0 ? { usage } : {}),
150
201
  }, null, 2)}\n`, "utf8");
151
202
  }
@@ -0,0 +1,231 @@
1
+ const asRecord = (v) => (v ?? {});
2
+ /** Nome do escopo para as mensagens - `.` quando a medição não escopou nada. */
3
+ const nameOf = (c, i) => c.scope ?? (i === 0 ? "." : `#${i + 1}`);
4
+ /**
5
+ * OS TOKENS, com o primeiro escopo mandando - e o conflito registrado.
6
+ *
7
+ * `declared` é o coração: é dele que saem os nomes que viajam para a tela. Um
8
+ * `--brand-500` que vale `#1394dc` na biblioteca e `#0f6fa8` no pacote de
9
+ * gráficos não é um erro de ninguém, é uma divergência - e a lei do dono
10
+ * (§8.6) é que a plataforma não decide sozinha: ela mostra os dois e diz quem é
11
+ * a fonte declarada.
12
+ */
13
+ function mergeDeclared(into, from, keptScope, fromScope, conflicts) {
14
+ for (const [name, value] of Object.entries(from)) {
15
+ const held = into[name];
16
+ if (held === undefined) {
17
+ into[name] = value;
18
+ continue;
19
+ }
20
+ if (held === value)
21
+ continue;
22
+ conflicts.push({
23
+ kind: "token",
24
+ name,
25
+ kept: { scope: keptScope, value: held },
26
+ lost: { scope: fromScope, value },
27
+ });
28
+ }
29
+ }
30
+ /** Os valores medidos, somados por (natureza, valor) - e o nome vem de quem o tem. */
31
+ function mergeObserved(a, b) {
32
+ const out = a.map((v) => ({ ...v }));
33
+ const at = new Map(out.map((v, i) => [`${v.kind}:${v.value}`, i]));
34
+ for (const v of b) {
35
+ const key = `${v.kind}:${v.value}`;
36
+ const i = at.get(key);
37
+ if (i === undefined) {
38
+ at.set(key, out.length);
39
+ out.push({ ...v });
40
+ continue;
41
+ }
42
+ out[i].count += v.count;
43
+ out[i].files += v.files;
44
+ /** Um nome não se apaga por o outro escopo não ter um. */
45
+ if (!out[i].token && v.token)
46
+ out[i].token = v.token;
47
+ }
48
+ return out.sort((x, y) => y.count - x.count);
49
+ }
50
+ /** União preservando a ordem de chegada - a do primeiro escopo primeiro. */
51
+ const union = (a, b) => [
52
+ ...new Set([...(a ?? []), ...(b ?? [])]),
53
+ ];
54
+ const concat = (a, b) => [
55
+ ...(a ?? []),
56
+ ...(b ?? []),
57
+ ];
58
+ /** Soma campo a campo dois objetos de contagem, mantendo o que só um tem. */
59
+ function sumInto(a, b) {
60
+ if (!a)
61
+ return b;
62
+ if (!b)
63
+ return a;
64
+ const out = { ...a };
65
+ for (const [k, v] of Object.entries(b)) {
66
+ const mine = out[k];
67
+ if (typeof v === "number" && typeof mine === "number")
68
+ out[k] = mine + v;
69
+ else if (mine === undefined)
70
+ out[k] = v;
71
+ }
72
+ return out;
73
+ }
74
+ /**
75
+ * OS CENSOS, FUNDIDOS NA ORDEM EM QUE A PESSOA OS PEDIU.
76
+ *
77
+ * Devolve o próprio objeto quando há um só: o caminho comum - um escopo - não
78
+ * paga nada por esta função existir.
79
+ */
80
+ export function mergeCensus(list) {
81
+ if (list.length === 0)
82
+ throw new Error("mergeCensus needs at least one census");
83
+ if (list.length === 1)
84
+ return list[0];
85
+ const first = list[0];
86
+ const conflicts = [];
87
+ const collisions = [
88
+ ...(first.collisions ?? []),
89
+ ];
90
+ const declared = { ...asRecord(first.declared) };
91
+ const declaredAlt = { ...asRecord(first.declaredAlt) };
92
+ const keyframes = { ...(first.keyframes ?? {}) };
93
+ const looks = { ...(first.looks ?? {}) };
94
+ let observed = first.observed.map((v) => ({ ...v }));
95
+ let totals = { ...first.totals };
96
+ let coverage = first.coverage;
97
+ let gate = first.gate;
98
+ let reachability = first.reachability;
99
+ let usage = first.usage ?? undefined;
100
+ let animations = first.animations ?? undefined;
101
+ let skipped = first.skipped ?? undefined;
102
+ let brokenRefs = first.brokenRefs ?? undefined;
103
+ let conventions = first.conventions ?? undefined;
104
+ let architectures = first.architectures ?? undefined;
105
+ let components = first.components ?? undefined;
106
+ let defined = first.defined ?? undefined;
107
+ const stack = [...(first.project.stack ?? [])];
108
+ for (let i = 1; i < list.length; i++) {
109
+ const c = list[i];
110
+ const scopeName = nameOf(c, i);
111
+ const firstName = nameOf(first, 0);
112
+ mergeDeclared(declared, asRecord(c.declared), firstName, scopeName, conflicts);
113
+ mergeDeclared(declaredAlt, asRecord(c.declaredAlt), firstName, scopeName, conflicts);
114
+ /**
115
+ * O COMPONENTE QUE OS DOIS DEFINEM fica com a receita do primeiro escopo, e
116
+ * o descarte entra em `collisions` - a mesma lista que `name-claim.ts`
117
+ * alimenta dentro de UM escopo. Uma lista, dois níveis: o arquivo e o
118
+ * pacote, e a tela não precisa saber a diferença para dizer a verdade.
119
+ */
120
+ for (const [name, look] of Object.entries(c.looks ?? {})) {
121
+ if (looks[name] === undefined) {
122
+ looks[name] = look;
123
+ continue;
124
+ }
125
+ /** `by` diz que este empate foi resolvido pela ORDEM DOS ESCOPOS. */
126
+ collisions.push({
127
+ name,
128
+ kept: firstName,
129
+ dropped: scopeName,
130
+ by: "scope",
131
+ });
132
+ }
133
+ for (const [name, frames] of Object.entries(c.keyframes ?? {}))
134
+ if (keyframes[name] === undefined)
135
+ keyframes[name] = frames;
136
+ observed = mergeObserved(observed, c.observed);
137
+ totals = {
138
+ scanned: totals.scanned + c.totals.scanned,
139
+ values: totals.values + c.totals.values,
140
+ named: totals.named + c.totals.named,
141
+ tokenUses: totals.tokenUses + c.totals.tokenUses,
142
+ ownUses: (totals.ownUses ?? 0) + (c.totals.ownUses ?? 0),
143
+ phantomUses: (totals.phantomUses ?? 0) + (c.totals.phantomUses ?? 0),
144
+ /** Recalculado abaixo - somar percentuais não significa nada. */
145
+ coverage: 0,
146
+ };
147
+ coverage = sumInto(coverage, c.coverage);
148
+ gate = sumInto(gate, c.gate);
149
+ reachability = sumInto(reachability, c.reachability);
150
+ usage = union(usage, c.usage);
151
+ animations = union(animations, c.animations);
152
+ skipped = concat(skipped, c.skipped);
153
+ brokenRefs = concat(brokenRefs, c.brokenRefs);
154
+ conventions = concat(conventions, c.conventions);
155
+ architectures = concat(architectures, c.architectures);
156
+ components = concat(components, c.components);
157
+ defined = concat(defined, c.defined);
158
+ for (const s of c.project.stack ?? [])
159
+ if (!stack.includes(s))
160
+ stack.push(s);
161
+ }
162
+ /**
163
+ * A COBERTURA, RECALCULADA COM A FÓRMULA DE VERDADE.
164
+ *
165
+ * `(tokenUses + ownUses) / (tokenUses + ownUses + values + phantomUses)` - a
166
+ * mesma de `doctor/scan.ts:796`. A primeira versão desta função usou
167
+ * `named/values`, que parece razoável e não é a fórmula: fundir 48% e 30%
168
+ * devolvia 10% no monorepo do dono. Um número que não descreve nenhuma das
169
+ * medições é pior que não ter o número.
170
+ *
171
+ * Quando os censos vêm de um CLI que não gravava `ownUses`, a soma perde esses
172
+ * termos e o resultado seria menor que a verdade - então neste caso a
173
+ * cobertura do PRIMEIRO escopo é preservada, que é uma medição real de uma
174
+ * parte real, em vez de uma conta feita com metade dos termos.
175
+ */
176
+ const named = totals.tokenUses + (totals.ownUses ?? 0);
177
+ const denominator = named + totals.values + (totals.phantomUses ?? 0);
178
+ const complete = list.every((c) => c.totals.ownUses !== undefined);
179
+ totals.coverage = !complete
180
+ ? first.totals.coverage
181
+ : denominator > 0
182
+ ? Math.round((named / denominator) * 100)
183
+ : 100;
184
+ const out = {
185
+ ...first,
186
+ project: { ...first.project, stack },
187
+ /**
188
+ * `scope` SEGUE SENDO O PRIMEIRO, e não uma lista, porque há quem compare por
189
+ * igualdade: `live-census.ts:44` faz `census.scope === LIBRARY_SCOPE`, e um
190
+ * array ali seria um falso silencioso. `scopes` é aditivo - quem não sabe
191
+ * dele continua lendo o censo como antes.
192
+ */
193
+ scopes: list.map((c, i) => nameOf(c, i)),
194
+ declared,
195
+ observed,
196
+ totals,
197
+ };
198
+ if (Object.keys(declaredAlt).length > 0)
199
+ out.declaredAlt = declaredAlt;
200
+ if (Object.keys(keyframes).length > 0)
201
+ out.keyframes = keyframes;
202
+ if (Object.keys(looks).length > 0)
203
+ out.looks = looks;
204
+ if (coverage)
205
+ out.coverage = coverage;
206
+ if (gate)
207
+ out.gate = gate;
208
+ if (reachability)
209
+ out.reachability = reachability;
210
+ if (usage && usage.length > 0)
211
+ out.usage = usage;
212
+ if (animations && animations.length > 0)
213
+ out.animations = animations;
214
+ if (skipped && skipped.length > 0)
215
+ out.skipped = skipped;
216
+ if (brokenRefs && brokenRefs.length > 0)
217
+ out.brokenRefs = brokenRefs;
218
+ if (conventions && conventions.length > 0)
219
+ out.conventions = conventions;
220
+ if (architectures && architectures.length > 0)
221
+ out.architectures = architectures;
222
+ if (components && components.length > 0)
223
+ out.components = components;
224
+ if (defined && defined.length > 0)
225
+ out.defined = defined;
226
+ if (collisions.length > 0)
227
+ out.collisions = collisions;
228
+ if (conflicts.length > 0)
229
+ out.scopeConflicts = conflicts;
230
+ return out;
231
+ }
@@ -62,10 +62,10 @@ export function claimName(claims, name, file) {
62
62
  const incumbentWins = depth(held) < depth(file) ||
63
63
  (depth(held) === depth(file) && held.localeCompare(file) <= 0);
64
64
  if (incumbentWins) {
65
- claims.collisions.push({ name, kept: held, dropped: file });
65
+ claims.collisions.push({ name, kept: held, dropped: file, by: "path" });
66
66
  return false;
67
67
  }
68
68
  claims.by.set(name, file);
69
- claims.collisions.push({ name, kept: file, dropped: held });
69
+ claims.collisions.push({ name, kept: file, dropped: held, by: "path" });
70
70
  return true;
71
71
  }
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join, relative } from "node:path";
3
+ import { SKIP } from "./commands/doctor.js";
3
4
  const MANIFEST = "package.json";
4
5
  const SHEET = /\.(css|scss|sass|less)$/i;
5
6
  const DECL = /(^|[\s{;])--[a-zA-Z0-9-]+\s*:/g;
@@ -28,9 +29,11 @@ function workspaceGlobs(p) {
28
29
  * Quantas custom properties um pacote declara - o mesmo peso que
29
30
  * `siblingProjects` já usa para escolher entre `packages/core` e `packages/ui`.
30
31
  *
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.
32
+ * A LISTA DE PASTAS IGNORADAS É A DO `walk`, importada e não reescrita. A primeira
33
+ * versão pulava dotdir e `node_modules` por conta própria e somava
34
+ * `packages/ui/dist/index.css`: 496 declarações de bundle contra 97 de fonte no
35
+ * repo do dono (19/08). O número que escolhia a pasta recomendada era cinco
36
+ * sextos build output, e o censo - que usa o SKIP - nunca leu nada daquilo.
34
37
  */
35
38
  async function weigh(dir) {
36
39
  let declarations = 0;
@@ -39,7 +42,7 @@ async function weigh(dir) {
39
42
  if (depth > 8)
40
43
  return;
41
44
  for (const e of await readdir(at, { withFileTypes: true }).catch(() => [])) {
42
- if (e.name.startsWith(".") || e.name === "node_modules")
45
+ if (e.name.startsWith(".") || SKIP.has(e.name))
43
46
  continue;
44
47
  const full = join(at, e.name);
45
48
  if (e.isDirectory()) {
@@ -125,16 +128,29 @@ export async function placeInWorkspace(root) {
125
128
  declarations,
126
129
  files,
127
130
  dependsOn: Object.keys(deps).filter((k) => local.has(k)),
131
+ /** Preenchido no passo seguinte, quando todos os `dependsOn` existem. */
132
+ importedBy: 0,
128
133
  });
129
134
  }
135
+ for (const pkg of packages) {
136
+ if (!pkg.name)
137
+ continue;
138
+ pkg.importedBy = packages.filter((other) => other !== pkg && other.dependsOn.includes(pkg.name)).length;
139
+ }
130
140
  if (packages.length === 0)
131
141
  return null;
132
142
  const rel = relative(workspaceRoot, root).split("\\").join("/");
133
- const ranked = [...packages].sort((a, b) => b.declarations - a.declarations);
143
+ /**
144
+ * A ORDEM: quem os outros importam vem primeiro, e a contagem só desempata.
145
+ * Um pacote que ninguém importa e que declara muito é um consumidor com
146
+ * vocabulário próprio - é um candidato legítimo, e não é a biblioteca.
147
+ */
148
+ const ranked = [...packages].sort((a, b) => b.importedBy - a.importedBy || b.declarations - a.declarations);
149
+ const lead = ranked[0];
134
150
  return {
135
151
  workspaceRoot,
136
152
  here: rel === "" ? null : rel,
137
153
  packages,
138
- vocabulary: ranked[0]?.declarations > 0 ? ranked[0] : null,
154
+ vocabulary: lead && (lead.declarations > 0 || lead.importedBy > 0) ? lead : null,
139
155
  };
140
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.253",
3
+ "version": "0.16.255",
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": {