synthesisui 0.16.256 → 0.16.259

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/dist/claude-md.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { hasHook } from "./agent-wiring.js";
4
+ import { declaredReference } from "./group-role.js";
4
5
  const START = "<!-- synthesisui:start -->";
5
6
  const END = "<!-- synthesisui:end -->";
6
7
  /** Reads the installed DSs from the .lock files in _synthesisui/ds/<slug>/. */
@@ -326,7 +327,7 @@ async function renderRegion(projectRoot, installed) {
326
327
  * repo de um sistema só para negar uma relação que ninguém cogitou é ruído no
327
328
  * arquivo mais lido do projeto.
328
329
  */
329
- const ref = installed.find((d) => d.reference)?.reference;
330
+ const ref = declaredReference(installed);
330
331
  if (installed.length > 1 && ref) {
331
332
  sections.push(`\n**${ref.name}** (\`${ref.slug}\`) is the reference system of this group. When two of the systems above name the same thing differently, its answer is the one to follow - the others are moving towards it.`);
332
333
  }
@@ -49,7 +49,8 @@ export async function absorb(opts) {
49
49
  * dela - o mesmo caminho que `absorb: "code"` já servia a quem escolheu. Ver `sendProposal`.
50
50
  */
51
51
  const hasSystem = Boolean(installed.table.slug);
52
- const measured = await measuredScope(root);
52
+ /** O escopo DESTE sistema - ver `actingSlug`: sem o slug o vizinho responde. */
53
+ const measured = await measuredScope(root, installed.table.slug ?? undefined);
53
54
  const rel = scopePaths(measured);
54
55
  const roots = rel.length > 0 ? rel.map((s) => resolve(root, s)) : [root];
55
56
  /**
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { syncClaudeMd } from "../claude-md.js";
4
4
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
5
  import { customFontFamilies, googleFontsHref, nextFontSnippet, } from "../fonts.js";
6
+ import { lockReference } from "../group-role.js";
6
7
  import { buildGuide } from "../guide.js";
7
8
  import { censusScope } from "../measured-scope.js";
8
9
  import { body as line, section, snippet } from "../output.js";
@@ -195,15 +196,19 @@ export async function add(slug, opts) {
195
196
  /**
196
197
  * A RELAÇÃO ENTRE OS SISTEMAS DESTE REPO, vinda da plataforma.
197
198
  *
198
- * Preservada quando o registry não a manda: um servidor mais antigo não sabe
199
- * do campo, e apagar a relação por causa disso faria a resposta oscilar entre
200
- * dois `add` seguidos.
199
+ * `payload.group` é a resposta COMPLETA - ver `RegistryPayload.group`: quando
200
+ * ela vem, ela manda, inclusive para APAGAR a referência. Foi a metade que
201
+ * faltava: clicar "eles são independentes" na tela do grupo não chegava aqui,
202
+ * porque o campo ausente era indistinguível de servidor antigo.
203
+ *
204
+ * Sem `group`, o comportamento antigo continua - preservar o que está no lock,
205
+ * senão a resposta oscilaria entre dois `add` contra um registry que não
206
+ * conhece o campo.
201
207
  */
202
- ...(payload.groupReference
203
- ? { reference: payload.groupReference }
204
- : prev?.reference
205
- ? { reference: prev.reference }
206
- : {}),
208
+ ...(() => {
209
+ const reference = lockReference(payload, prev);
210
+ return reference ? { reference } : {};
211
+ })(),
207
212
  };
208
213
  const landed = (() => {
209
214
  if (!prev?.fetchedAt)
@@ -6,6 +6,7 @@ import { isOlderCli } from "../cli-version.js";
6
6
  import { readToken, resolveRegistry } from "../config.js";
7
7
  import { unsentEvents } from "../doctor/ledger.js";
8
8
  import { readRequests } from "../doctor/requests.js";
9
+ import { declaredReference } from "../group-role.js";
9
10
  import { CHECKER_SINCE, installedBehind, MATERIALISER_SINCE, READER_SINCE, } from "../install-marks.js";
10
11
  import { measuredScope } from "../measured-scope.js";
11
12
  import { body } from "../output.js";
@@ -110,7 +111,7 @@ opts = {}) {
110
111
  * comandos seguem agindo sobre o primeiro, e agora dá para dizer QUAL deveria
111
112
  * ser. Sem referência, o aviso é o de sempre.
112
113
  */
113
- const declared = locks.find((l) => l.reference)?.reference;
114
+ const declared = declaredReference(locks);
114
115
  if (locks.length > 1) {
115
116
  const names = locks.map((l) => l.slug).join(", ");
116
117
  if (declared && locks.some((l) => l.slug === declared.slug)) {
@@ -161,7 +162,7 @@ opts = {}) {
161
162
  * e manda os componentes de todos os apps para um sistema que é uma biblioteca (06/08, 338 num
162
163
  * sistema de 36).
163
164
  */
164
- const scope = await measuredScope(root);
165
+ const scope = await measuredScope(root, lock.slug);
165
166
  if (!scope.system && scope.usage.length === 0)
166
167
  out.push({
167
168
  says: `"${lock.slug}" does not record where it was measured, so a re-measure would read this whole repo instead of the folder the system came from. Sync asks once and remembers the answer.`,
@@ -16,7 +16,8 @@ import { diagnose, nameToWrite, scanSource, siblingTokens, } from "../doctor/sca
16
16
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
17
17
  import { withTheirNames } from "../doctor/their-names.js";
18
18
  import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
19
- import { describeScope, measuredScope, scopePaths } from "../measured-scope.js";
19
+ import { groupRole } from "../group-role.js";
20
+ import { actingSlug, describeScope, measuredScope, scopePaths, } from "../measured-scope.js";
20
21
  import { body, paint, section, snippet } from "../output.js";
21
22
  import { resolveDeps } from "../stack.js";
22
23
  /**
@@ -192,6 +193,7 @@ measured) {
192
193
  documents: [],
193
194
  requires: [],
194
195
  doctrines: [],
196
+ groupLocks: [],
195
197
  };
196
198
  }
197
199
  // Several systems can live side by side; every token is prefixed --ds-, so
@@ -203,6 +205,7 @@ measured) {
203
205
  const documents = [];
204
206
  const requires = [];
205
207
  const doctrines = [];
208
+ const groupLocks = [];
206
209
  for (const slug of slugs) {
207
210
  const dir = join(dsDir, slug);
208
211
  const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
@@ -214,6 +217,11 @@ measured) {
214
217
  mine = null;
215
218
  }
216
219
  lock ??= mine;
220
+ if (mine?.slug)
221
+ groupLocks.push({
222
+ slug: mine.slug,
223
+ ...(mine.reference ? { reference: mine.reference } : {}),
224
+ });
217
225
  // The file at the root is a POINTER - `@import "./v1/tokens.css"` - so the
218
226
  // pinned folder is where the declarations actually live. Read that first,
219
227
  // and fall back to following whatever the root imports, for a project that
@@ -331,6 +339,7 @@ measured) {
331
339
  documents,
332
340
  requires,
333
341
  doctrines,
342
+ groupLocks,
334
343
  };
335
344
  }
336
345
  /**
@@ -566,7 +575,15 @@ export async function doctor(opts) {
566
575
  */
567
576
  const measured = (opts.scopes ?? []).length > 0
568
577
  ? { system: null, systems: [], usage: [], from: "none" }
569
- : await measuredScope(root);
578
+ : /**
579
+ * O ESCOPO DO SISTEMA SOBRE O QUAL ESTE COMANDO AGE - ver `actingSlug`.
580
+ *
581
+ * Sem o slug, um repo de dois sistemas podia responder com o escopo do
582
+ * outro: `scopeInLock` devolvia o primeiro lock QUE TEM escopo, e o
583
+ * comando age no primeiro lock que EXISTE. Num monorepo isso é a
584
+ * diferença entre ler a biblioteca e ler um app de 780 arquivos.
585
+ */
586
+ await measuredScope(root, (await actingSlug(root)) ?? undefined);
570
587
  const relScopes = (opts.scopes ?? []).length > 0
571
588
  ? opts.scopes
572
589
  : scopePaths(measured);
@@ -582,7 +599,6 @@ export async function doctor(opts) {
582
599
  */
583
600
  const fullRun = (opts.scopes ?? []).length === 0;
584
601
  /** A intenção ordena o relatório, e a precedência é flag > config > default. */
585
- const intent = intentOf(await readProjectConfig(root), opts.intent);
586
602
  /**
587
603
  * A RAIZ, MEDIDA ANTES DE COMPARAR VALOR NENHUM - ver `rootSizeOf`.
588
604
  *
@@ -595,6 +611,16 @@ export async function doctor(opts) {
595
611
  px: rootSize.ambiguous ? DEFAULT_ROOT_PX : rootSize.px,
596
612
  from: rootSize.from,
597
613
  });
614
+ /**
615
+ * A INTENÇÃO SABE DA REFERÊNCIA - é o que faz as duas superfícies falarem a
616
+ * mesma língua.
617
+ *
618
+ * A tela do grupo declara qual sistema é a fonte; este comando ordena a lista
619
+ * por isso, sem perguntar de novo. Um sistema que não é a referência do seu
620
+ * grupo está migrando para ela - e antes disto a referência existia na
621
+ * plataforma e o comando que ordena a lista nunca ficava sabendo.
622
+ */
623
+ const intent = intentOf(await readProjectConfig(root), opts.intent, groupRole(installed.groupLocks, installed.table.slug));
598
624
  const { recipes, documents } = installed;
599
625
  /**
600
626
  * A TABELA JÁ VEM COM O VOCABULÁRIO DELE DENTRO - ver `loadSystem` e `their-names.ts`.
@@ -2155,6 +2155,31 @@ function sayScopes(c) {
2155
2155
  console.log("");
2156
2156
  console.log(section("Two folders, one system"));
2157
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
+ /**
2159
+ * A CONVERGÊNCIA VEM ANTES DOS CONFLITOS, porque ela decide se a lista de
2160
+ * conflitos é o assunto.
2161
+ *
2162
+ * Com 60% compartilhado, três divergências são o trabalho. Com 0%, elas são um
2163
+ * detalhe de dois vocabulários que não se encontram - e a conversa que precisa
2164
+ * acontecer é outra: isto é um sistema ou são dois? Medido no monorepo do dono,
2165
+ * `packages/ui` e `apps/web-dashboard` dão exatamente 0%.
2166
+ */
2167
+ const conv = c.scopeConvergence;
2168
+ if (conv && conv.percent !== null) {
2169
+ console.log("");
2170
+ console.log(body(conv.covered === 0
2171
+ ? `${paint.strong(`None of the ${conv.declares} names`)} the other scope${scopes.length > 2 ? "s" : ""} declare${scopes.length > 2 ? "" : "s"} exists in ${paint.strong(scopes[0])} yet - not by name and not by value. These are different vocabularies, not one drifting from the other.`
2172
+ : `${paint.strong(`${conv.covered} of ${conv.declares} names`)} the other scope${scopes.length > 2 ? "s" : ""} declare${scopes.length > 2 ? "" : "s"} already exist in ${paint.strong(scopes[0])} (${conv.percent}%)${conv.aliased > 0 ? `, and ${conv.aliased} of those under a DIFFERENT name - one decision written twice` : ""}.`));
2173
+ /**
2174
+ * E A PERGUNTA, dita em voz alta em vez de assumida.
2175
+ *
2176
+ * A fusão presume que os escopos são um sistema só. Abaixo de um terço de
2177
+ * convergência essa premissa deixa de se sustentar sozinha, e quem decide não
2178
+ * é a esteira - é quem conhece os dois produtos.
2179
+ */
2180
+ if (conv.percent < 34)
2181
+ console.log(body(paint.faint("Worth answering before this becomes one system: are these two ONE vocabulary, or two that live in the same repo? Two is a legitimate answer - import each scope on its own, and name which of them is the reference on the group's screen.")));
2182
+ }
2158
2183
  if (conflicts.length === 0)
2159
2184
  return;
2160
2185
  console.log("");
@@ -34,7 +34,7 @@ export async function summary(slug, opts) {
34
34
  const lock = await readFile(join(root, "_synthesisui", "ds", slug, ".lock"), "utf8")
35
35
  .then((raw) => JSON.parse(raw))
36
36
  .catch(() => null);
37
- const measured = await measuredScope(root);
37
+ const measured = await measuredScope(root, slug);
38
38
  const name = lock?.name ?? slug;
39
39
  /**
40
40
  * `looks`, NUNCA `components`.
@@ -73,7 +73,26 @@ export async function sync(opts) {
73
73
  console.log(body(" synthesisui login"));
74
74
  return;
75
75
  }
76
- const slug = await installedSlug(root);
76
+ /**
77
+ * QUAL SISTEMA - e num repo com dois isto deixou de ser óbvio.
78
+ *
79
+ * `installedSlug` devolve o primeiro em ordem alfabética, então o segundo
80
+ * sistema de um repo NUNCA era sincronizado: o comando não tinha como
81
+ * alcançá-lo. O `align` avisava ("commands act on the first") e não havia o que
82
+ * fazer com o aviso.
83
+ *
84
+ * `synthesisui sync <slug>` resolve, e sem argumento nada muda para quem tem um
85
+ * sistema só - que é praticamente todo mundo.
86
+ */
87
+ const installed = await installedSlugs(root);
88
+ const slug = opts.slug
89
+ ? installed.find((x) => x === opts.slug)
90
+ : installed[0];
91
+ if (opts.slug && !slug) {
92
+ console.log("");
93
+ console.log(body(`No system called ${paint.strong(opts.slug)} is installed here.${installed.length > 0 ? ` This repo has: ${installed.map((x) => paint.strong(x)).join(", ")}.` : ""}`));
94
+ return;
95
+ }
77
96
  if (!slug) {
78
97
  console.log(section("Sync"));
79
98
  console.log(body("No installed system here, so there is nowhere to sync to."));
@@ -247,7 +266,14 @@ export async function remeasure(args) {
247
266
  *
248
267
  * O servidor continua valendo como segunda fonte: um clone fresco tem o `.lock` e não tem censo.
249
268
  */
250
- const local = await measuredScope(root);
269
+ /**
270
+ * O ESCOPO DESTE SISTEMA, e não o do vizinho - ver `measuredScope`.
271
+ *
272
+ * Num repo com dois sistemas instalados, o escopo vinha do primeiro lock que
273
+ * tivesse um, enquanto `installedSlug` escolhia o primeiro que tivesse slug.
274
+ * Quando não eram o mesmo, este comando media um sistema e mandava para o outro.
275
+ */
276
+ const local = await measuredScope(root, slug);
251
277
  /**
252
278
  * TODOS os escopos do sistema, não só o primeiro.
253
279
  *
@@ -506,35 +532,36 @@ async function askToOverwrite() {
506
532
  }
507
533
  }
508
534
  /**
509
- * O SLUG QUE ESTE PROJETO TEM INSTALADO, ou `null`.
535
+ * TODOS os sistemas instalados, na ordem em que o disco os devolve.
536
+ *
537
+ * Era `installedSlug`, singular, e devolvia o primeiro - o que fazia o segundo
538
+ * sistema de um repo ser inalcançável por este comando. O `align` avisava
539
+ * ("commands act on the first") e não havia o que fazer com o aviso.
510
540
  *
511
- * Uma função e não duas cópias: o `sync` e a ferramenta `refresh_system` do MCP precisam da mesma
512
- * busca, e a regra que ela carrega - um DS ADOTADO é descrito aqui e pertencido em outro lugar, então
513
- * não tem linha no dashboard para onde sincronizar - discordaria entre duas cópias na primeira vez que
514
- * alguém a mudasse.
541
+ * `adopted` fica fora: um sistema adotado é o vocabulário do próprio projeto, sem
542
+ * versão publicada, e não há para onde sincronizá-lo.
515
543
  */
516
- export async function installedSlug(root) {
544
+ export async function installedSlugs(root) {
517
545
  const dsDir = join(root, "_synthesisui", "ds");
518
- try {
519
- const slugs = (await readdir(dsDir, { withFileTypes: true }))
520
- .filter((e) => e.isDirectory())
521
- .map((e) => e.name);
522
- for (const name of slugs) {
523
- const raw = await readFile(join(dsDir, name, ".lock"), "utf8").catch(() => "");
524
- try {
525
- const lock = JSON.parse(raw);
526
- if (lock.slug && !lock.adopted)
527
- return lock.slug;
528
- }
529
- catch {
530
- // unreadable lock - try the next
531
- }
546
+ const out = [];
547
+ const slugs = (await readdir(dsDir, { withFileTypes: true }).catch(() => []))
548
+ .filter((d) => d.isDirectory())
549
+ .map((d) => d.name)
550
+ .sort();
551
+ for (const name of slugs) {
552
+ const raw = await readFile(join(dsDir, name, ".lock"), "utf8").catch(() => null);
553
+ if (!raw)
554
+ continue;
555
+ try {
556
+ const lock = JSON.parse(raw);
557
+ if (lock.slug && !lock.adopted)
558
+ out.push(lock.slug);
559
+ }
560
+ catch {
561
+ // lock ilegível: aquele sistema não entra, e o resto segue
532
562
  }
533
563
  }
534
- catch {
535
- // no _synthesisui at all
536
- }
537
- return null;
564
+ return out;
538
565
  }
539
566
  /**
540
567
  * DE ONDE ESTE SISTEMA FOI MEDIDO - perguntado uma vez, com os candidatos do próprio repo.
@@ -577,3 +604,13 @@ async function askForScope(root) {
577
604
  rl.close();
578
605
  }
579
606
  }
607
+ /**
608
+ * O PRIMEIRO sistema instalado - o que o MCP usa quando a pergunta é "deste repo".
609
+ *
610
+ * Mantido porque as ferramentas do MCP falam de UM sistema por natureza: quem
611
+ * pergunta "qual é a doutrina daqui" quer a do sistema que governa este repo, e a
612
+ * escolha entre dois é uma decisão de quem opera, não do servidor.
613
+ */
614
+ export async function installedSlug(root) {
615
+ return (await installedSlugs(root))[0] ?? null;
616
+ }
package/dist/config.js CHANGED
@@ -156,7 +156,19 @@ export async function readProjectConfig(root) {
156
156
  * porque é o único dos dois cuja lista tem conserto automático (`--fix`). Migrar é uma fase, e uma
157
157
  * fase se declara.
158
158
  */
159
- export function intentOf(config, flag) {
159
+ export function intentOf(config, flag,
160
+ /**
161
+ * O PAPEL DESTE SISTEMA NO GRUPO, lido dos locks por `groupRole`.
162
+ *
163
+ * Recebe o papel inteiro e não um slug solto, porque as duas metades decidem
164
+ * coisas opostas e um argumento que se pode esquecer é um argumento que alguém
165
+ * vai esquecer: `follows` sozinho derivaria `adopt` para a própria referência.
166
+ *
167
+ * Derivar em vez de perguntar de novo é o que faz as duas superfícies falarem a
168
+ * mesma língua - a tela do grupo declara uma vez, e o doctor de todo repo do
169
+ * grupo passa a ordenar por aquilo.
170
+ */
171
+ group) {
160
172
  if (flag === "migrate" || flag === "adopt")
161
173
  return { intent: flag, from: "flag" };
162
174
  if (config.intent)
@@ -164,7 +176,14 @@ export function intentOf(config, flag) {
164
176
  intent: config.intent,
165
177
  from: "config",
166
178
  ...(config.intentAt ? { at: config.intentAt } : {}),
179
+ ...(group?.follows ? { towards: group.follows } : {}),
167
180
  };
181
+ /** Sou a fonte: o design dos outros vem para cá, e absorver é o que eu faço. */
182
+ if (group?.isReference)
183
+ return { intent: "migrate", from: "reference" };
184
+ /** Sigo a fonte: o meu vocabulário converge, então eu adoto os nomes dela. */
185
+ if (group?.follows)
186
+ return { intent: "adopt", from: "reference", towards: group.follows };
168
187
  return { intent: "adopt", from: "default" };
169
188
  }
170
189
  /**
@@ -183,7 +202,18 @@ export function describeIntent(source) {
183
202
  ? "this run only"
184
203
  : source.from === "config"
185
204
  ? `set in _synthesisui/config.json${source.at ? ` on ${source.at.slice(0, 10)}` : ""}`
186
- : "nobody chose, so this is the default";
205
+ : source.from === "reference"
206
+ ? /**
207
+ * A frase diz que ninguém digitou isto, e de onde saiu: uma intenção
208
+ * derivada que se apresenta como escolhida é a etiqueta que envelhece
209
+ * sem ninguém notar. E ela nomeia o outro sistema nos dois sentidos,
210
+ * porque "sou a referência" e "sigo a referência" ordenam a lista de
211
+ * jeitos opostos.
212
+ */
213
+ source.towards
214
+ ? `derived: this group's reference is \`${source.towards}\`, and your vocabulary is converging on it`
215
+ : "derived: this is the group's reference system, so the design moves in"
216
+ : "nobody chose, so this is the default";
187
217
  const flip = source.intent === "migrate"
188
218
  ? "`doctor --adopt` sorts the other way"
189
219
  : "`doctor --migrate` sorts the other way";
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Lê o papel dos locks instalados. Sem referência declarada no grupo, os dois
3
+ * campos ficam vazios: independentes é uma resposta, não a ausência de uma.
4
+ */
5
+ export function groupRole(locks,
6
+ /**
7
+ * `null` quando não há sistema instalado - e aí não há papel, nem derivação. É
8
+ * o estado de um projeto que só rodou `import`: sem lock, sem grupo.
9
+ */
10
+ self) {
11
+ if (!self)
12
+ return { isReference: false };
13
+ const mine = locks.find((l) => l.slug === self);
14
+ const follows = mine?.reference?.slug;
15
+ /**
16
+ * `follows` e `isReference` não coexistem: quem segue não é seguido. Se um repo
17
+ * chegar nesse estado, o lock que aponta para mim está velho - e é o `align` que
18
+ * fala de lock velho, não esta função, que responde pelo MEU lock.
19
+ */
20
+ const isReference = !follows &&
21
+ locks.some((l) => l.reference?.slug === self && l.slug !== self);
22
+ return {
23
+ ...(follows ? { follows } : {}),
24
+ isReference,
25
+ };
26
+ }
27
+ /**
28
+ * QUE REFERÊNCIA O `.lock` PASSA A GUARDAR depois de uma busca no registry.
29
+ *
30
+ * Uma função em vez de um ternário dentro do `add` porque a decisão tem três
31
+ * entradas e um caso que ninguém adivinha (apagar), e um ternário de três níveis
32
+ * no meio de um objeto de 12 campos é onde o caso apagar se perdeu por semanas.
33
+ */
34
+ export function lockReference(payload, prev) {
35
+ /** A resposta completa manda, e ela também apaga. */
36
+ if (payload.group)
37
+ return payload.group.reference;
38
+ /** Um servidor que só conhece a metade antiga: ele afirma, nunca nega. */
39
+ if (payload.groupReference)
40
+ return payload.groupReference;
41
+ return prev?.reference;
42
+ }
43
+ /**
44
+ * A REFERÊNCIA DO GRUPO, lida dos locks - a mesma leitura para todo mundo.
45
+ *
46
+ * Três lugares faziam esta linha à mão (`align`, o `CLAUDE.md` e o doctor), e
47
+ * "todos falam a mesma língua" começa por ler o fato no mesmo lugar. O lock que
48
+ * aponta para si mesmo é ignorado: é lock velho, e ele nomearia como referência do
49
+ * grupo alguém que já não é.
50
+ */
51
+ export function declaredReference(locks) {
52
+ return locks.find((l) => l.reference && l.reference.slug !== l.slug)
53
+ ?.reference;
54
+ }
package/dist/index.js CHANGED
@@ -65,7 +65,7 @@ Usage - governance (deterministic, FREE):
65
65
  something no reader would change
66
66
  synthesisui request [component|token] the queue of what your agent needed and the system
67
67
  refused to invent (--done <id> closes one)
68
- synthesisui sync re-measure this repo with the current reader and send it,
68
+ synthesisui sync [<slug>] re-measure this repo with the current reader and send it,
69
69
  reusing the reading you already authored, plus the local
70
70
  record - checks, fixes, open requests (--record-only skips
71
71
  the measurement, --yes skips the overwrite question)
@@ -528,6 +528,14 @@ async function main() {
528
528
  dir,
529
529
  registry,
530
530
  cli: CLI_VERSION,
531
+ /**
532
+ * `sync <slug>`: qual sistema, num repo que tem mais de um.
533
+ *
534
+ * Sem ele o comando agia sempre no primeiro em ordem alfabética, e o
535
+ * segundo sistema de um repositório era inalcançável - o `align` avisava
536
+ * e não havia o que fazer com o aviso.
537
+ */
538
+ ...(args[0] ? { slug: args[0] } : {}),
531
539
  recordOnly: boolFlag(flags, "record-only"),
532
540
  yes: boolFlag(flags, "yes"),
533
541
  /** `--full`: o relatório inteiro do leitor. Por padrão a re-medição conta o que MUDOU. */
@@ -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.256";
103
+ export const MATERIALISER_SINCE = "0.16.259";
104
104
  /**
105
105
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
106
106
  *
@@ -16,12 +16,26 @@ const EMPTY = {
16
16
  *
17
17
  * Um lock sem `scope` é o caso de todo sistema instalado antes disto: cai no censo, como antes.
18
18
  */
19
- async function scopeInLock(root) {
19
+ async function scopeInLock(root,
20
+ /**
21
+ * DE QUAL SISTEMA - e sem isto o escopo era o do PRIMEIRO diretório que tinha um.
22
+ *
23
+ * Com dois sistemas instalados (um fork da galeria, sem escopo, e um importado,
24
+ * com escopo) o laço pulava o primeiro e devolvia o escopo do segundo, enquanto
25
+ * `installedSlug` devolvia o PRIMEIRO. O `sync` então media o escopo de um
26
+ * sistema e mandava para o outro - provado num repo de teste em 19/08.
27
+ *
28
+ * Ausente mantém o comportamento antigo, que é o certo para um sistema só.
29
+ */
30
+ slug) {
20
31
  const dsDir = join(root, "_synthesisui", "ds");
21
32
  const names = await readdir(dsDir, { withFileTypes: true }).catch(() => []);
22
33
  for (const entry of names) {
23
34
  if (!entry.isDirectory())
24
35
  continue;
36
+ /** Pedido um sistema, é o dele que responde - nunca o do vizinho. */
37
+ if (slug && entry.name !== slug)
38
+ continue;
25
39
  const raw = await readFile(join(dsDir, entry.name, ".lock"), "utf8").catch(() => null);
26
40
  if (!raw)
27
41
  continue;
@@ -75,6 +89,37 @@ async function scopeInLock(root) {
75
89
  async function present(root, rel) {
76
90
  return stat(resolve(root, rel)).then((s) => s.isDirectory(), () => false);
77
91
  }
92
+ /**
93
+ * O SISTEMA SOBRE O QUAL OS COMANDOS AGEM - o primeiro em ordem alfabética.
94
+ *
95
+ * Não é uma escolha nova: é a que `loadSystem` já faz (`slugs.sort()`, e o
96
+ * primeiro lock legível ganha), e o `align` já avisa sobre ela. Ela existe aqui
97
+ * para que quem precisa do ESCOPO possa dizer DE QUEM - sem isto, `measuredScope`
98
+ * responde com o primeiro lock que tem escopo, que num repo de dois sistemas pode
99
+ * ser o do outro. Provado em 19/08: um fork sem escopo mais um importado com
100
+ * escopo, e a medição de um ia para o outro.
101
+ */
102
+ export async function actingSlug(root) {
103
+ const dsDir = join(root, "_synthesisui", "ds");
104
+ const dirs = (await readdir(dsDir, { withFileTypes: true }).catch(() => []))
105
+ .filter((d) => d.isDirectory())
106
+ .map((d) => d.name)
107
+ .sort();
108
+ for (const name of dirs) {
109
+ const raw = await readFile(join(dsDir, name, ".lock"), "utf8").catch(() => null);
110
+ if (!raw)
111
+ continue;
112
+ try {
113
+ const lock = JSON.parse(raw);
114
+ if (lock.slug)
115
+ return lock.slug;
116
+ }
117
+ catch {
118
+ // lock ilegível: o próximo responde
119
+ }
120
+ }
121
+ return null;
122
+ }
78
123
  /**
79
124
  * Lê `_synthesisui/census.json` e devolve o que ele diz sobre ONDE.
80
125
  *
@@ -82,8 +127,17 @@ async function present(root, rel) {
82
127
  * desde então não pode fazer o doctor estourar - ela simplesmente sai da lista, e a lista vazia cai no
83
128
  * comportamento antigo (a raiz).
84
129
  */
85
- export async function measuredScope(root) {
86
- const fromLock = await scopeInLock(root);
130
+ export async function measuredScope(root,
131
+ /**
132
+ * O SISTEMA CUJO ESCOPO SE QUER - e passar isto deixou de ser opcional na
133
+ * prática quando um repo pode ter dois.
134
+ *
135
+ * Quem sincroniza um sistema tem que medir O ESCOPO DELE. Sem o slug, o
136
+ * primeiro lock com escopo respondia por todos, e num repo com um fork sem
137
+ * escopo mais um importado com escopo o `sync` media um e mandava para o outro.
138
+ */
139
+ slug) {
140
+ const fromLock = await scopeInLock(root, slug);
87
141
  if (fromLock)
88
142
  return fromLock;
89
143
  return censusScope(root);
@@ -227,5 +227,57 @@ export function mergeCensus(list) {
227
227
  out.collisions = collisions;
228
228
  if (conflicts.length > 0)
229
229
  out.scopeConflicts = conflicts;
230
+ /**
231
+ * A CONVERGÊNCIA ENTRE OS ESCOPOS, calculada aqui porque é aqui que os
232
+ * vocabulários separados ainda existem - depois da fusão eles são um só.
233
+ *
234
+ * `shared` conta os nomes que mais de um escopo declara; `agree` são os que
235
+ * resolvem igual. Zero compartilhado não é "quase lá": é a informação de que
236
+ * não há design em comum, e que fundir produziria a média de dois sistemas que
237
+ * não se parecem.
238
+ */
239
+ {
240
+ /**
241
+ * O NÚMERO É DIRECIONAL: quanto do vocabulário dos escopos SEGUINTES o
242
+ * PRIMEIRO já nomeia. A primeira versão media `agree/union`, simétrico, e o
243
+ * dono estranhou na hora - com razão: um percentual simétrico lê como NOTA, e
244
+ * se os escopos são vocabulários diferentes de propósito não existe nota a
245
+ * dar. A pergunta útil é a da migração, e ela tem um sentido.
246
+ *
247
+ * E conta POR VALOR além do nome: `--dashboard-blue-500` e `--color-blue-500`
248
+ * são a mesma decisão com dois nomes - invisível numa comparação por nome, e
249
+ * é o item mais acionável que sai daqui (dois casos no repo do dono).
250
+ */
251
+ const norm = (v) => v.trim().toLowerCase().replace(/\s+/g, " ");
252
+ const first = asRecord(list[0].declared);
253
+ const byValue = new Map();
254
+ for (const [name, value] of Object.entries(first))
255
+ if (!byValue.has(norm(value)))
256
+ byValue.set(norm(value), name);
257
+ let declares = 0;
258
+ let covered = 0;
259
+ let aliased = 0;
260
+ for (const c of list.slice(1)) {
261
+ for (const [name, value] of Object.entries(asRecord(c.declared))) {
262
+ declares += 1;
263
+ const sameName = first[name];
264
+ if (sameName !== undefined) {
265
+ if (sameName === value)
266
+ covered += 1;
267
+ continue;
268
+ }
269
+ if (byValue.has(norm(value))) {
270
+ covered += 1;
271
+ aliased += 1;
272
+ }
273
+ }
274
+ }
275
+ out.scopeConvergence = {
276
+ declares,
277
+ covered,
278
+ aliased,
279
+ percent: declares === 0 ? null : Math.round((covered / declares) * 100),
280
+ };
281
+ }
230
282
  return out;
231
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.256",
3
+ "version": "0.16.259",
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": {