synthesisui 0.16.153 → 0.16.154

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.
@@ -0,0 +1,154 @@
1
+ import { deltaE, JND } from "./doctor/color-distance.js";
2
+ import { nearestToken } from "./doctor/tokens.js";
3
+ /** Onde cada tipo de valor mora na fundação. `color` é o único com dois segmentos. */
4
+ const HOME = {
5
+ color: "color",
6
+ radius: "radius",
7
+ spacing: "spacing",
8
+ font: "typography",
9
+ motion: "motion.durations",
10
+ };
11
+ /** Uma palavra de categoria no começo do nome deles não é família - é a categoria repetida. */
12
+ const CATEGORY = /^(color|colour|radius|rounded|spacing|space|gap|font|type|duration|motion|ease|easing)-/;
13
+ const clean = (name) => name.replace(/^--/, "").toLowerCase();
14
+ /**
15
+ * O CAMINHO A PARTIR DO NOME DELES, e nada além dele.
16
+ *
17
+ * Cor precisa de família E step, e a forma que o mundo real produz é `<família>-<número>` - a mesma que
18
+ * o censo lê para montar as rampas deles. Sem o número não há step, e inventar um (`.500`) seria
19
+ * escolher uma posição na escala por eles: aí o valor volta sem caminho e a pessoa decide.
20
+ *
21
+ * O namespace é PRESERVADO de propósito: um repo com `--color-lightgray-300` e
22
+ * `--dashboard-lightgray-300` tem duas famílias, e `dashboard-lightgray` é o nome honesto da segunda.
23
+ */
24
+ export function pathFor(kind, theirName) {
25
+ if (!theirName)
26
+ return "";
27
+ const bare = clean(theirName).replace(CATEGORY, "");
28
+ if (!bare)
29
+ return "";
30
+ if (kind === "color") {
31
+ const m = /^(.*[a-z0-9])-(\d{2,4})$/.exec(bare);
32
+ return m ? `color.${m[1]}.${m[2]}` : "";
33
+ }
34
+ return `${HOME[kind]}.${bare}`;
35
+ }
36
+ /**
37
+ * O TOKEN DELES MAIS PRÓXIMO DESTE VALOR, quando existe um perto o bastante para valer a frase.
38
+ *
39
+ * Cor por ΔE76 (2.3 é a diferença que um olho começa a notar; abaixo disso são a mesma cor discutindo).
40
+ * Comprimento pelo mesmo `nearestToken` que o doctor já usa para dizer "snap to". Motion e tipografia
41
+ * ficam de fora: 0.2s e 0.25s não são "quase iguais", são durações diferentes de propósito.
42
+ */
43
+ function nearestOwn(theirs, literal, kind) {
44
+ if (kind === "color") {
45
+ let best = null;
46
+ for (const [name, value] of theirs.byName) {
47
+ const d = deltaE(literal, value);
48
+ if (d === null || d === 0)
49
+ continue;
50
+ if (best === null || d < best.away)
51
+ best = { name, value, away: d };
52
+ }
53
+ /** Três ΔE: perto o bastante para alguém querer olhar, longe o bastante para não ser óbvio. */
54
+ if (!best || best.away > JND * 1.3)
55
+ return undefined;
56
+ return {
57
+ name: clean(best.name),
58
+ value: best.value,
59
+ away: `${best.away.toFixed(1)} ΔE`,
60
+ };
61
+ }
62
+ if (kind === "radius" || kind === "spacing") {
63
+ const hit = nearestToken(theirs, literal);
64
+ return hit
65
+ ? {
66
+ name: clean(hit.name),
67
+ value: hit.value,
68
+ away: `${hit.delta}${/rem$/.test(literal) ? "rem" : "px"} apart`,
69
+ }
70
+ : undefined;
71
+ }
72
+ return undefined;
73
+ }
74
+ /**
75
+ * A PROPOSTA: os valores mais repetidos que o sistema não nomeia, com o caminho quando ele é derivável.
76
+ *
77
+ * `theirs` é a tabela do vocabulário DELES (`buildTable({ source: "yours" })`), lida da raiz mesmo
78
+ * quando a medição é escopada - é o mesmo princípio que já vale no doctor: o vocabulário vem da raiz, só
79
+ * a leitura é escopada.
80
+ *
81
+ * `have` são os caminhos que a fundação já tem, para a proposta nunca sugerir sobrescrever um token
82
+ * existente com outro valor - isso não é absorver, é repintar.
83
+ */
84
+ export function absorbPlan(d, theirs, have, cap = 40) {
85
+ const unnamed = d.repeats.filter((r) => !r.token);
86
+ const entries = [];
87
+ for (const r of unnamed) {
88
+ if (entries.length >= cap)
89
+ break;
90
+ const theirNames = theirs.byValue.get(r.literal.toLowerCase()) ?? [];
91
+ const theirName = theirNames[0];
92
+ const path = pathFor(r.kind, theirName);
93
+ const near = nearestOwn(theirs, r.literal, r.kind);
94
+ /** Já existe na fundação com OUTRO valor: absorver aqui seria repintar o token deles. */
95
+ if (path && have.has(path))
96
+ continue;
97
+ entries.push({
98
+ value: r.literal,
99
+ kind: r.kind,
100
+ files: r.files,
101
+ count: r.count,
102
+ path,
103
+ ...(theirName ? { theirName: clean(theirName) } : {}),
104
+ ...(near ? { near } : {}),
105
+ });
106
+ }
107
+ return { entries, more: Math.max(0, unnamed.length - entries.length) };
108
+ }
109
+ /** Quantas linhas da proposta ainda precisam de um nome humano. */
110
+ export const needingName = (plan) => plan.entries.filter((e) => !e.path);
111
+ /**
112
+ * A proposta em texto - e ela lidera pelo que está PRONTO para enviar.
113
+ *
114
+ * Uma lista que abre pelas pendências faz parecer que nada pode ser feito hoje; a maioria das linhas de
115
+ * um repo em migração já tem nome no vocabulário do próprio cliente, e essas viajam sem ninguém digitar
116
+ * nada.
117
+ */
118
+ export function describeAbsorb(plan) {
119
+ const ready = plan.entries.filter((e) => e.path);
120
+ const pending = needingName(plan);
121
+ const lines = [];
122
+ if (plan.entries.length === 0)
123
+ return [
124
+ "Nothing to absorb: every repeated value your code writes by hand already has a name in the system.",
125
+ ];
126
+ lines.push(`${ready.length} of ${plan.entries.length} values already have a name in YOUR vocabulary - those travel as they are:`);
127
+ for (const e of ready.slice(0, 8))
128
+ lines.push(` ${e.value.padEnd(24)} ${e.path.padEnd(28)} ${e.files} file${e.files === 1 ? "" : "s"}${e.theirName ? ` · your ${e.theirName}` : ""}`);
129
+ if (ready.length > 8)
130
+ lines.push(` (${ready.length - 8} more)`);
131
+ if (pending.length > 0) {
132
+ const close = pending.filter((e) => e.near);
133
+ lines.push("");
134
+ lines.push(`${pending.length} nobody names yet. Naming is a design decision, so those wait for a word from you:`);
135
+ for (const e of pending.slice(0, 8))
136
+ lines.push(` ${e.value.padEnd(24)} ${`<${e.kind}, ${e.files} file${e.files === 1 ? "" : "s"}>`.padEnd(28)} ${e.near ? `≈ your ${e.near.name} (${e.near.away})` : 'path: ""'}`);
137
+ if (pending.length > 8)
138
+ lines.push(` (${pending.length - 8} more)`);
139
+ /**
140
+ * E A LEITURA QUE MUDA O TRABALHO: quando a maioria dos sem-nome está perto de um token que eles já
141
+ * têm, isto não é uma fila de batismos - é uma fila de NORMALIZAÇÃO, e sai muito mais barato.
142
+ */
143
+ if (close.length > 0) {
144
+ lines.push("");
145
+ lines.push(`${close.length} of those ${close.length === 1 ? "is" : "are"} within a hair of a token you already have (marked ≈). Those are probably not new names - they are the same decision written twice, and pointing the code at what you have is cheaper than naming them.`);
146
+ }
147
+ }
148
+ /** O teto NUNCA é silencioso: um corte que não se diz lê como "é tudo o que existe". */
149
+ if (plan.more > 0) {
150
+ lines.push("");
151
+ lines.push(`${plan.more} more unnamed values are below the cut - the file holds the ${plan.entries.length} most repeated. Absorb these, re-run, and the next batch appears.`);
152
+ }
153
+ return lines;
154
+ }
@@ -0,0 +1,211 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { absorbPlan, describeAbsorb, needingName, } from "../absorb-plan.js";
4
+ import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
5
+ import { diagnose, scanSource } from "../doctor/scan.js";
6
+ import { buildTable } from "../doctor/tokens.js";
7
+ import { measuredScope, scopePaths } from "../measured-scope.js";
8
+ import { body, paint, section, snippet } from "../output.js";
9
+ import { loadSystem, walkAll } from "./doctor.js";
10
+ /**
11
+ * `synthesisui absorb` - O SISTEMA APRENDE O DESIGN QUE O CÓDIGO JÁ TEM.
12
+ *
13
+ * O outro sentido da esteira, e o que faltava para o ciclo de migração fechar (dono, 06/08, movendo
14
+ * design de três apps para `packages/ui`):
15
+ *
16
+ * doctor --migrate 4223 valores que o sistema não nomeia, por frequência
17
+ * absorb ESTE: eles viram tokens do sistema
18
+ * upgrade o repo pega a versão nova
19
+ * doctor --fix agora eles têm nome, e a troca é automática
20
+ *
21
+ * DUAS FASES, e a do meio é humana de propósito. A primeira escreve uma PROPOSTA em
22
+ * `_synthesisui/absorb.json`: cada valor com o caminho derivado do nome que eles já usam, e vazio
23
+ * quando ninguém nomeia. A pessoa preenche os vazios num arquivo, de uma vez - vinte perguntas no
24
+ * terminal é como uma migração vira uma tarde perdida. Aí `--send` manda o lote.
25
+ *
26
+ * O arquivo é o contrato entre as duas fases, e é revisável por outra pessoa antes de ir: um lote de
27
+ * tokens novos num sistema compartilhado é exatamente o tipo de mudança que um time quer ver num diff.
28
+ */
29
+ const FILE = "_synthesisui/absorb.json";
30
+ export async function absorb(opts) {
31
+ const root = resolve(opts.dir ?? process.cwd());
32
+ const path = join(root, FILE);
33
+ if (opts.send)
34
+ return sendProposal(root, path, opts.registry);
35
+ const installed = await loadSystem(root);
36
+ if (!installed.table.slug) {
37
+ console.log(section("Absorb"));
38
+ console.log(body("No system installed here, so there is nothing to absorb INTO. `synthesisui list` shows what you can add."));
39
+ return;
40
+ }
41
+ const measured = await measuredScope(root);
42
+ const rel = scopePaths(measured);
43
+ const roots = rel.length > 0 ? rel.map((s) => resolve(root, s)) : [root];
44
+ /**
45
+ * DUAS TABELAS, e é a diferença entre elas que define o trabalho.
46
+ *
47
+ * A nossa diz o que o sistema JÁ nomeia - o que ela nomeia é assunto do `--fix`. A DELES diz como
48
+ * eles chamam o valor, e é de lá que o nome sai. Lida da RAIZ mesmo quando a medição é escopada: o
49
+ * vocabulário é do projeto, e um app que pega um token emprestado do `packages/ui` deles não o
50
+ * redeclara.
51
+ */
52
+ let css = "";
53
+ for await (const file of walkAll([root]))
54
+ if (/\.(css|scss|sass|less)$/i.test(file))
55
+ css += `\n${await readFile(file, "utf8").catch(() => "")}`;
56
+ const theirs = buildTable({ css, source: "yours" });
57
+ const reports = [];
58
+ for await (const file of walkAll(roots)) {
59
+ const source = await readFile(file, "utf8").catch(() => null);
60
+ if (source === null)
61
+ continue;
62
+ reports.push(scanSource(file.slice(root.length + 1), source, installed.table));
63
+ }
64
+ const plan = absorbPlan(diagnose(reports), theirs, pathsInSystem(installed.documents), opts.cap ?? 40);
65
+ console.log(section("What the system could absorb"));
66
+ console.log(body(paint.dim(`read from ${rel.length > 0 ? rel.join(", ") : "this project"} · your own vocabulary read from the root`)));
67
+ console.log("");
68
+ for (const line of describeAbsorb(plan))
69
+ console.log(body(line));
70
+ if (plan.entries.length === 0)
71
+ return;
72
+ const proposal = {
73
+ slug: installed.table.slug,
74
+ at: new Date().toISOString(),
75
+ scope: rel,
76
+ entries: plan.entries,
77
+ more: plan.more,
78
+ };
79
+ await mkdir(dirname(path), { recursive: true });
80
+ await writeFile(path, `${JSON.stringify(proposal, null, 2)}\n`, "utf8");
81
+ console.log("");
82
+ console.log(body(`Written to ${FILE} - nothing was sent.`));
83
+ const pending = needingName(plan).length;
84
+ if (pending > 0)
85
+ console.log(body(`Fill the ${pending} empty \`path\` field${pending === 1 ? "" : "s"} with the name you want (\`color.<family>.<step>\`, \`spacing.<key>\`), delete the lines you do not want, then:`));
86
+ else
87
+ console.log(body("Delete the lines you do not want and send the rest with:"));
88
+ console.log(paint.blue(snippet(["npx synthesisui@latest absorb --send"])));
89
+ }
90
+ /**
91
+ * OS CAMINHOS QUE A FUNDAÇÃO JÁ TEM, lidos do documento que o `add` deixou ao lado dos tokens.
92
+ *
93
+ * Serve para a proposta nunca sugerir escrever num token existente: isso não seria absorver o design
94
+ * deles, seria repintar uma decisão que eles já tomaram. A plataforma recusa de novo do outro lado - ela
95
+ * é a autoridade -, e recusar aqui evita a pessoa preencher um nome que vai ser rejeitado.
96
+ */
97
+ function pathsInSystem(documents) {
98
+ const out = new Set();
99
+ const walk = (node, path) => {
100
+ if (node && typeof node === "object" && !Array.isArray(node))
101
+ for (const [k, v] of Object.entries(node))
102
+ walk(v, [...path, k]);
103
+ else if (typeof node === "string" && path.length > 0)
104
+ out.add(path.join("."));
105
+ };
106
+ for (const doc of documents) {
107
+ const d = (doc ?? {});
108
+ const foundations = d.foundations ?? {};
109
+ const color = (foundations.color ?? {});
110
+ walk(color.primitives, ["color"]);
111
+ for (const [key, value] of Object.entries(foundations))
112
+ if (key !== "color")
113
+ walk(value, [key]);
114
+ walk(d.motion?.durations, ["motion", "durations"]);
115
+ walk(d.motion?.easings, ["motion", "easings"]);
116
+ }
117
+ return out;
118
+ }
119
+ /** Manda o lote gravado. Só o que tem caminho viaja - o resto ainda espera um nome. */
120
+ async function sendProposal(root, path, registry) {
121
+ const raw = await readFile(path, "utf8").catch(() => null);
122
+ if (!raw) {
123
+ console.log(section("Absorb"));
124
+ console.log(body(`No proposal at ${FILE}. Run \`npx synthesisui@latest absorb\` first - it writes one and sends nothing.`));
125
+ return;
126
+ }
127
+ let proposal;
128
+ try {
129
+ proposal = JSON.parse(raw);
130
+ }
131
+ catch {
132
+ console.log(section("Absorb"));
133
+ console.log(body(`${FILE} is not readable JSON anymore. Fix the file, or delete it and run \`absorb\` again.`));
134
+ return;
135
+ }
136
+ const ready = (proposal.entries ?? []).filter((e) => typeof e.path === "string" && e.path.trim().length > 0);
137
+ const waiting = (proposal.entries ?? []).length - ready.length;
138
+ if (ready.length === 0) {
139
+ console.log(section("Absorb"));
140
+ console.log(body(`Every line in ${FILE} still has an empty \`path\`. A name is the one thing this cannot decide for you.`));
141
+ return;
142
+ }
143
+ /**
144
+ * O ALVO DECIDE SE ISSO SAI DA MÁQUINA - ver `ProjectConfig.absorb`.
145
+ *
146
+ * `code`: o vocabulário canônico é o CSS deles, então a nossa parte é devolver o bloco e a deles é
147
+ * colar. Nunca escrevemos no arquivo do cliente - e o import seguinte lê os tokens de volta, o que faz
148
+ * o caminho fechar sem nós tocarmos em nada.
149
+ */
150
+ const target = (await readProjectConfig(root)).absorb ?? "system";
151
+ if (target === "code") {
152
+ console.log(section("Paste this into your own vocabulary"));
153
+ console.log(body(paint.dim("Your config says absorbed values belong in your CSS, so nothing was sent.")));
154
+ console.log("");
155
+ console.log(" :root {");
156
+ for (const e of ready)
157
+ console.log(` --${e.path.replace(/\./g, "-")}: ${e.value};`);
158
+ console.log(" }");
159
+ console.log("");
160
+ console.log(body("The next import or `sync` reads these back as tokens you declared, and from then on the doctor cobra them by your own names."));
161
+ return;
162
+ }
163
+ const token = await readToken();
164
+ if (!token) {
165
+ console.log(section("Absorb"));
166
+ console.log(body("Sending needs a login - the tokens land in YOUR system:"));
167
+ console.log(paint.blue(snippet(["npx synthesisui@latest login"])));
168
+ return;
169
+ }
170
+ const base = resolveRegistry(registry);
171
+ const res = await fetch(`${base}/api/registry/ds/${proposal.slug}/absorb`, {
172
+ method: "POST",
173
+ headers: {
174
+ "content-type": "application/json",
175
+ Authorization: `Bearer ${token}`,
176
+ },
177
+ body: JSON.stringify({
178
+ tokens: ready.map((e) => ({
179
+ path: e.path.trim(),
180
+ value: e.value,
181
+ ...(e.theirName ? { was: e.theirName } : {}),
182
+ })),
183
+ }),
184
+ }).catch(() => null);
185
+ console.log(section("Absorb"));
186
+ if (!res?.ok) {
187
+ console.log(body(res
188
+ ? `The registry answered ${res.status}. Nothing was absorbed, and ${FILE} is untouched - fix and send again.`
189
+ : "Could not reach the registry. Nothing was absorbed."));
190
+ return;
191
+ }
192
+ const answer = (await res.json());
193
+ console.log(body(`${answer.added ?? 0} token${answer.added === 1 ? "" : "s"} are now part of your system${answer.draft ? `, in the v${answer.draft} draft` : ""}.`));
194
+ for (const s of (answer.skipped ?? []).slice(0, 8))
195
+ console.log(body(paint.dim(` skipped ${s.path} - ${s.why}`)));
196
+ if (waiting > 0)
197
+ console.log(body(paint.dim(`${waiting} line${waiting === 1 ? "" : "s"} stayed behind with no name. They are still in ${FILE}.`)));
198
+ /**
199
+ * E O CICLO SE FECHA AQUI, dito em voz alta.
200
+ *
201
+ * Absorver não muda o código: os literais continuam escritos à mão até alguém trocá-los, e agora
202
+ * existe por quem trocar. Ninguém adivinharia essa ordem - foi ela que faltou na primeira conversa
203
+ * sobre migração (dono, 06/08).
204
+ */
205
+ console.log("");
206
+ console.log(body("Two commands and the code catches up:"));
207
+ console.log(paint.blue(snippet([
208
+ `npx synthesisui@latest upgrade ${proposal.slug}`,
209
+ "npx synthesisui@latest doctor --fix",
210
+ ])));
211
+ }
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
+ import { describeIntent, intentOf, readProjectConfig } from "../config.js";
3
4
  import { describeFix, planFix, readerFor, writeFix, } from "../doctor/apply-fix.js";
4
5
  import { baselineOf, compareToBaseline, describeVerdict, githubAnnotations, sarifOf, } from "../doctor/ci-format.js";
5
6
  import { findDivergences } from "../doctor/coherence.js";
@@ -13,6 +14,7 @@ import { checkableName, readRequests, verifyAndCloseRequests, } from "../doctor/
13
14
  import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
14
15
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
15
16
  import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
17
+ import { describeScope, measuredScope, scopePaths } from "../measured-scope.js";
16
18
  import { body, paint, section, snippet } from "../output.js";
17
19
  import { resolveDeps } from "../stack.js";
18
20
  /**
@@ -412,7 +414,32 @@ async function readWiring(root, slug) {
412
414
  const DEFAULT_BASELINE = "_synthesisui/drift-baseline.json";
413
415
  export async function doctor(opts) {
414
416
  const root = resolve(opts.dir ?? process.cwd());
415
- const scopes = (opts.scopes ?? []).map((s) => resolve(root, s));
417
+ /**
418
+ * O ESCOPO QUE O IMPORT JÁ GRAVOU - ver `measured-scope.ts`.
419
+ *
420
+ * Um caminho passado à mão manda, sempre: quem digitou sabe o que quer. Sem ele, a resposta já está
421
+ * no disco (`census.json`: `scope` + `usage`), e varrer a raiz ignorando isso foi o que produziu uma
422
+ * lista de prioridades tirada de um app consumidor num repo de 2787 arquivos (dono, 06/08).
423
+ */
424
+ const measured = (opts.scopes ?? []).length > 0
425
+ ? { system: null, usage: [], from: "none" }
426
+ : await measuredScope(root);
427
+ const relScopes = (opts.scopes ?? []).length > 0
428
+ ? opts.scopes
429
+ : scopePaths(measured);
430
+ const scopes = relScopes.map((s) => resolve(root, s));
431
+ /**
432
+ * CORRIDA COMPLETA É "NINGUÉM DIGITOU UM CAMINHO", e não "varreu tudo".
433
+ *
434
+ * O ledger só guarda retrato de corrida completa, porque a cobertura de um canto na linha do tempo do
435
+ * projeto lê como o projeto tendo melhorado quando alguém só apontou a ferramenta para outra pasta.
436
+ * Com o escopo agora vindo do censo, `scopes.length === 0` deixaria de acontecer - e o histórico
437
+ * pararia de existir em todo repo que tem censo. O que caracteriza a corrida completa é a INTENÇÃO de
438
+ * medir o projeto, e ela é: nenhum caminho na linha de comando.
439
+ */
440
+ const fullRun = (opts.scopes ?? []).length === 0;
441
+ /** A intenção ordena o relatório, e a precedência é flag > config > default. */
442
+ const intent = intentOf(await readProjectConfig(root), opts.intent);
416
443
  const installed = await loadSystem(root);
417
444
  const { recipes, documents } = installed;
418
445
  let table = installed.table;
@@ -533,9 +560,24 @@ export async function doctor(opts) {
533
560
  : table.source === "yours"
534
561
  ? `Your own tokens - ${plural(table.byName.size, "token")} found, ${plural(d.scanned, "file")} read`
535
562
  : `No system installed - ${plural(d.scanned, "file")} read`));
563
+ /**
564
+ * ONDE ELE LEU, SEMPRE - e de onde essa decisão veio.
565
+ *
566
+ * "2787 files read" sem dizer o universo é um número que a pessoa preenche com o que imagina, e ela
567
+ * imagina o sistema dela. Quando o escopo veio do censo, a frase nomeia os dois papéis: a pasta que
568
+ * É o sistema, e os apps que o CONSOMEM.
569
+ */
536
570
  if (scopes.length > 0) {
537
- console.log(body(`scope: ${opts.scopes?.join(", ")}`));
571
+ const said = measured.from === "census" ? describeScope(measured) : null;
572
+ console.log(body(said ?? `scope: ${relScopes.join(", ")}`));
538
573
  }
574
+ /**
575
+ * E A INTENÇÃO, em toda rodada, com a data e o jeito de inverter.
576
+ *
577
+ * É o que impede uma fase declarada em agosto de ordenar o relatório em dezembro sem ninguém saber
578
+ * por quê - a mesma regra que fez `not observed since` existir em vez de um `fixed` inferido.
579
+ */
580
+ console.log(body(paint.dim(describeIntent(intent))));
539
581
  // What a token could never have held, said out loud. A report that quietly
540
582
  // drops things is only trustworthy until someone notices, and then it is
541
583
  // worth less than one that says too much.
@@ -574,6 +616,15 @@ export async function doctor(opts) {
574
616
  // Fonts only count as missing when `init` actually wrote the file - a
575
617
  // non-Next project has no fonts.ts and must not be nagged about one.
576
618
  const fontsPending = wiring.fontsWritten && !wiring.fontsMapped;
619
+ /**
620
+ * A FIAÇÃO É PORTÃO, e não um aviso ao lado de um número.
621
+ *
622
+ * Sem o `@import` ou sem o `data-ds`, os tokens não chegam ao navegador. Até 06/08 a tela dizia isso
623
+ * e, três linhas abaixo, imprimia uma barra de 0% e uma lista de cinco prioridades - se contradizendo
624
+ * na mesma tela (dono: "fiquei perdido"). Ou o número não significa nada, ou existe uma lista de
625
+ * prioridades; as duas não cabem juntas.
626
+ */
627
+ const blocked = hasSystem && (!wiring.imported || !wiring.scoped);
577
628
  const unwired = table.source === "installed" &&
578
629
  (!wiring.imported || !wiring.scoped || fontsPending);
579
630
  if (unwired) {
@@ -598,10 +649,13 @@ export async function doctor(opts) {
598
649
  // working and the faces are not - saying "nothing reaches the browser"
599
650
  // there would be false, and a report that overstates is one nobody trusts
600
651
  // the next time.
601
- const blocked = !wiring.imported || !wiring.scoped;
602
652
  if (blocked) {
603
- console.log(body(`Until the first two are true, none of the ${table.byName.size} tokens reach the`));
604
- console.log(body("browser and the number below cannot mean anything."));
653
+ /**
654
+ * A FRASE SEGUE O QUE VEM DEPOIS DELA. Ela dizia "o número abaixo não pode significar nada"
655
+ * enquanto imprimia o número abaixo; agora não há número abaixo, e prometer um seria a mesma
656
+ * incoerência ao contrário.
657
+ */
658
+ console.log(body(`Until both are true, none of the ${table.byName.size} tokens reach the browser.`));
605
659
  }
606
660
  else {
607
661
  console.log(body("Colour and spacing are working. Type is not: the system's faces"));
@@ -610,6 +664,41 @@ export async function doctor(opts) {
610
664
  }
611
665
  console.log(body("The exact snippets are in the output of `init`."));
612
666
  }
667
+ /**
668
+ * E AQUI O RELATÓRIO PARA, quando a fiação está vermelha: uma instrução, e nada mais.
669
+ *
670
+ * O que fica de fora é justamente o que não pode ser verdade ainda - a barra de cobertura, a lista de
671
+ * prioridades e a oferta do `--fix`. O `--fix` é o mais grave: no repo do dono ele teria trocado 217
672
+ * literais por `var(--ds-…)` que não resolvem em lugar nenhum, e o navegador descarta a declaração -
673
+ * ou seja, o comando que promete adotar o sistema quebraria a aparência do app.
674
+ *
675
+ * O ledger é gravado ANTES de sair: a corrida aconteceu, e o histórico não pode depender de o
676
+ * projeto estar bem configurado.
677
+ */
678
+ if (blocked) {
679
+ if (fullRun)
680
+ await appendEvent(root, {
681
+ kind: "doctor",
682
+ at: new Date().toISOString(),
683
+ coverage: d.coverage,
684
+ named: d.named,
685
+ ...(d.findings.some((f) => f.crossFamily)
686
+ ? { crossFamily: d.findings.filter((f) => f.crossFamily).length }
687
+ : {}),
688
+ }).catch(() => { });
689
+ console.log("");
690
+ if (opts.fix) {
691
+ console.log(body(`--fix is refused while those two are false. ${d.named} of your hand-written values`));
692
+ console.log(body("do have a name in the system, and swapping them now would point them at"));
693
+ console.log(body("variables the browser cannot resolve - the declarations would be dropped"));
694
+ console.log(body("and the page would change. Wire the two lines first."));
695
+ }
696
+ else {
697
+ console.log(body(`${d.named} of the ${d.findings.length} hand-written values found already have a name in`));
698
+ console.log(body("your system, and that number becomes actionable the moment both are true."));
699
+ }
700
+ return;
701
+ }
613
702
  /**
614
703
  * SHADCN IS THE MOST LIKELY THING ALREADY IN THE PROJECT, AND THE BRIDGE WAS
615
704
  * INVISIBLE.
@@ -671,7 +760,7 @@ export async function doctor(opts) {
671
760
  * question a design-system lead asks before paying for anything, and until
672
761
  * today the honest answer was that nobody knew.
673
762
  */
674
- if (hasSystem && measurable && scopes.length === 0) {
763
+ if (hasSystem && measurable && fullRun) {
675
764
  /**
676
765
  * E A CONTAGEM DE COINCIDÊNCIAS VAI JUNTO - ver `crossFamily` em `scan.ts`.
677
766
  *
@@ -688,7 +777,7 @@ export async function doctor(opts) {
688
777
  ...(crossFamily > 0 ? { crossFamily } : {}),
689
778
  });
690
779
  }
691
- if (hasSystem && scopes.length === 0) {
780
+ if (hasSystem && fullRun) {
692
781
  const record = summarize(await readEvents(root));
693
782
  if (record.checks >= 2) {
694
783
  console.log("");
@@ -716,7 +805,7 @@ export async function doctor(opts) {
716
805
  * the considered-and-rejected entries arrive already argued, by the party
717
806
  * that actually hit the gap.
718
807
  */
719
- if (hasSystem && scopes.length === 0) {
808
+ if (hasSystem && fullRun) {
720
809
  // Verification first: whether an ask is still missing is a FACT about
721
810
  // the installed css, so the queue re-checks before it reports. A request
722
811
  // the build now delivers closes itself and says so - nobody marks done
@@ -1143,18 +1232,31 @@ export async function doctor(opts) {
1143
1232
  cheap: false,
1144
1233
  });
1145
1234
  }
1146
- for (const r of d.repeats.filter((x) => x.token).slice(0, 3)) {
1235
+ /**
1236
+ * AS DUAS INTENÇÕES ORDENAM ESTA LISTA AO CONTRÁRIO UMA DA OUTRA.
1237
+ *
1238
+ * `adopt`: o que o sistema JÁ nomeia vem primeiro - é troca de uma linha e o `--fix` faz.
1239
+ * `migrate`: o que ele NÃO nomeia vem primeiro - é a decisão de design que a biblioteca tem que
1240
+ * absorver, e é o trabalho de quem está movendo design de um app para um pacote compartilhado.
1241
+ *
1242
+ * As duas listas continuam presentes nos dois modos (dono, 06/08): esconder metade faria a tela
1243
+ * mentir por omissão. Muda a ordem e muda quantos de cada aparecem no teto de cinco.
1244
+ */
1245
+ const migrating = intent.intent === "migrate";
1246
+ const named = d.repeats.filter((x) => x.token);
1247
+ const unnamed = d.repeats.filter((x) => !x.token);
1248
+ for (const r of named.slice(0, migrating ? 2 : 3)) {
1147
1249
  plan.push({
1148
- rank: 3,
1250
+ rank: migrating ? 4 : 3,
1149
1251
  what: `${r.literal} → ${r.token}`,
1150
1252
  size: `${r.files} file${r.files === 1 ? "" : "s"}`,
1151
1253
  cheap: true,
1152
1254
  });
1153
1255
  }
1154
- for (const r of d.repeats.filter((x) => !x.token).slice(0, 2)) {
1256
+ for (const r of unnamed.slice(0, migrating ? 3 : 2)) {
1155
1257
  const near = nearestToken(table, r.literal);
1156
1258
  plan.push({
1157
- rank: 4,
1259
+ rank: migrating ? 3 : 4,
1158
1260
  what: near
1159
1261
  ? `${r.literal} - name it, or snap to ${near.name}`
1160
1262
  : `${r.literal} - the system has no name for it`,
@@ -1318,7 +1420,8 @@ export async function doctor(opts) {
1318
1420
  await writeFile(path, `${JSON.stringify(baselineOf(d, {
1319
1421
  at: new Date().toISOString(),
1320
1422
  ...(opts.cli ? { cli: opts.cli } : {}),
1321
- ...(opts.scopes?.length ? { scope: opts.scopes.join(",") } : {}),
1423
+ /** O escopo EFETIVO, e não a flag: comparar dois universos diferentes inventa regressão. */
1424
+ ...(relScopes.length ? { scope: relScopes.join(",") } : {}),
1322
1425
  }), null, 1)}\n`, "utf8");
1323
1426
  console.log(section("Baseline written"));
1324
1427
  console.log(body(`${d.findings.length} findings and ${d.phantomUses} phantom uses are now the line to hold. A run that goes above it fails; everything at or below it passes.`));
@@ -1337,7 +1440,7 @@ export async function doctor(opts) {
1337
1440
  return;
1338
1441
  }
1339
1442
  const verdict = compareToBaseline(d, base, {
1340
- ...(opts.scopes?.length ? { scope: opts.scopes.join(",") } : {}),
1443
+ ...(relScopes.length ? { scope: relScopes.join(",") } : {}),
1341
1444
  });
1342
1445
  console.log(section(verdict.worse ? "Drift went up" : "Ratchet"));
1343
1446
  for (const line of describeVerdict(verdict))
@@ -15,6 +15,16 @@ export async function init(opts) {
15
15
  pagesDir: opts.pagesDir ?? (target === "next" ? "app" : DEFAULT_CONFIG.pagesDir),
16
16
  componentsDir: opts.componentsDir ?? DEFAULT_CONFIG.componentsDir,
17
17
  styles: opts.styles === "tailwind" ? "tailwind" : "css",
18
+ /**
19
+ * Gravada COM A DATA, e só quando alguém respondeu. Migração é uma fase que termina, e uma fase sem
20
+ * data é a etiqueta que envelhece em silêncio - o doctor imprime as duas em toda rodada.
21
+ */
22
+ ...(opts.intent === "migrate" || opts.intent === "adopt"
23
+ ? { intent: opts.intent, intentAt: new Date().toISOString() }
24
+ : {}),
25
+ ...(opts.absorb === "code" || opts.absorb === "system"
26
+ ? { absorb: opts.absorb }
27
+ : {}),
18
28
  };
19
29
  await writeProjectConfig(root, config);
20
30
  console.log("✓ wrote _synthesisui/config.json");
@@ -22,6 +32,10 @@ export async function init(opts) {
22
32
  console.log(` pagesDir: ${config.pagesDir}`);
23
33
  console.log(` componentsDir: ${config.componentsDir}`);
24
34
  console.log(` styles: ${config.styles}`);
35
+ if (config.absorb)
36
+ console.log(` absorb: ${config.absorb}${config.absorb === "code" ? " (we hand you the CSS block; your file stays yours)" : " (absorbed values become tokens of the system)"}`);
37
+ if (config.intent)
38
+ console.log(` intent: ${config.intent}${config.intent === "migrate" ? " (the doctor leads with what your system does not name yet)" : " (the doctor leads with what your system already names)"}`);
25
39
  // --ds bootstraps the project with a system in one step (tokens + philosophy
26
40
  // + rules + CLAUDE.md all arrive via `add`).
27
41
  if (opts.ds) {
@@ -0,0 +1,79 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { measuredScope } from "../measured-scope.js";
4
+ import { body, paint, section, snippet } from "../output.js";
5
+ /**
6
+ * `synthesisui summary <slug>` - AS QUATRO LINHAS DO PRIMEIRO MINUTO, impressas por um programa.
7
+ *
8
+ * A skill `sui-init` já mandava terminar em quatro linhas, e listava o que nunca deveria aparecer
9
+ * nelas: quantos arquivos foram varridos, quanto tempo levou, quais leitores rodaram, o que o ledger
10
+ * diz. O fechamento que o dono recebeu em 06/08 tinha ~400 palavras e violava as duas regras - com
11
+ * três dos quatro parágrafos falando do AGENTE e não do sistema dele:
12
+ *
13
+ * "my notes from 2026-08-05 carry two confirmed defects" a memoria do agente
14
+ * "the generator had been deleted, so I rebuilt it" o retrabalho do agente
15
+ * "a census regression I stripped by hand" o conserto do agente
16
+ *
17
+ * E duas daquelas advertências estavam DESATUALIZADAS: os dois defeitos do `--fix` foram consertados
18
+ * na 0.16.153, então a skill assustou o usuário com um bug que não existe mais.
19
+ *
20
+ * Mais prosa proibindo não conserta isso. O que o agente tem em mãos no fim é justamente o próprio
21
+ * trabalho dele, e a atração de reportá-lo vence uma linha de proibição a sessenta linhas de distância.
22
+ * Então o fechamento sai das mãos dele: este comando sabe as contagens (o censo está no disco), sabe a
23
+ * versão instalada (o `.lock`), e recebe o resto por flag. Um agente que executa isto não tem o que
24
+ * compor - e uma anotação velha dele não tem por onde entrar.
25
+ *
26
+ * LOCAL, E NUNCA FALHA. Sem censo ele ainda diz o essencial; sem rede ele não perde nada, porque nada
27
+ * aqui é remoto.
28
+ */
29
+ export async function summary(slug, opts) {
30
+ const root = resolve(opts.dir ?? process.cwd());
31
+ const census = await readFile(join(root, "_synthesisui", "census.json"), "utf8")
32
+ .then((raw) => JSON.parse(raw))
33
+ .catch(() => null);
34
+ const lock = await readFile(join(root, "_synthesisui", "ds", slug, ".lock"), "utf8")
35
+ .then((raw) => JSON.parse(raw))
36
+ .catch(() => null);
37
+ const measured = await measuredScope(root);
38
+ const name = lock?.name ?? slug;
39
+ /**
40
+ * `looks`, NUNCA `components`.
41
+ *
42
+ * `components` é a lista de candidatos que o portão avaliou - 128 no repo do dono, contra os 36 que
43
+ * o sistema realmente tem. Dizer 128 no primeiro minuto é prometer quatro vezes o que foi entregue,
44
+ * e o número certo já estava no mesmo arquivo.
45
+ */
46
+ const components = census?.looks ? Object.keys(census.looks).length : null;
47
+ const tokens = census?.declared ? Object.keys(census.declared).length : null;
48
+ console.log(section(`${name} is yours`));
49
+ /**
50
+ * LINHA 1 - O QUE ELE TEM, nas contagens dele. Nunca "36 de 128 candidatos", nunca "2787 arquivos":
51
+ * o que importa no primeiro minuto é o tamanho do que ele ganhou e de onde saiu.
52
+ */
53
+ const from = measured.system ? `, read out of ${measured.system}` : "";
54
+ console.log(body(components != null && tokens != null
55
+ ? `${components} components and ${tokens} of your own tokens${from}.`
56
+ : `Installed${lock?.version ? ` at v${lock.version}` : ""}${from}.`));
57
+ /**
58
+ * LINHA 2 - AS DUAS VERSÕES E O CAMINHO DE VOLTA, com os números que a PLATAFORMA deu.
59
+ *
60
+ * Escrever "v1" e "v2" aqui por conta própria já esteve errado uma vez: enquanto o nascimento
61
+ * publicava uma baseline, o import caía na v2 e o congelamento produzia a v3. Então os números
62
+ * chegam por flag, e sem eles esta linha simplesmente não é dita.
63
+ */
64
+ if (opts.base != null && opts.draft != null) {
65
+ console.log(body(`v${opts.base} is your code as it is today${opts.fixes ? ` · v${opts.draft} adds ${opts.fixes} deterministic ${opts.fixes === 1 ? "fix" : "fixes"}` : ` · v${opts.draft} is the draft`}.`));
66
+ console.log(body(paint.dim(`Back to v${opts.base} is one click: /dashboard/mine/${slug}/publish`)));
67
+ }
68
+ /**
69
+ * LINHA 3 - O QUE FAZER AGORA, e é uma coisa só.
70
+ *
71
+ * As duas linhas de fiação são o portão de tudo o mais: sem elas nenhum token chega ao navegador e
72
+ * todo número seguinte é vazio. O `doctor` diz isso melhor e com o estado real, então o próximo passo
73
+ * é ele - não uma explicação nossa aqui.
74
+ */
75
+ console.log("");
76
+ console.log(body("Two lines wire it up, and `init` printed both:"));
77
+ console.log(body(paint.dim(` @import "./_synthesisui/ds/${slug}/tokens.css"; and data-ds="${slug}" on <html>`)));
78
+ console.log(paint.blue(snippet(["npx synthesisui@latest doctor"])));
79
+ }
package/dist/config.js CHANGED
@@ -98,12 +98,66 @@ export async function readProjectConfig(root) {
98
98
  ? parsed.componentsDir
99
99
  : DEFAULT_CONFIG.componentsDir,
100
100
  styles: parsed.styles === "tailwind" ? "tailwind" : "css",
101
+ /**
102
+ * A INTENÇÃO NÃO TEM DEFAULT AQUI, de propósito.
103
+ *
104
+ * Um default silencioso é uma decisão nossa vestida de decisão deles: quem não respondeu não
105
+ * escolheu, e o doctor tem que poder dizer "ninguém declarou" em vez de ordenar por um palpite.
106
+ * Quem consome isto é `intentOf`, que separa os três casos.
107
+ */
108
+ ...(parsed.intent === "migrate" || parsed.intent === "adopt"
109
+ ? { intent: parsed.intent }
110
+ : {}),
111
+ ...(typeof parsed.intentAt === "string" && parsed.intentAt
112
+ ? { intentAt: parsed.intentAt }
113
+ : {}),
114
+ /** Sem valor gravado o comando usa `system`, que é o default do produto e não uma suposição. */
115
+ ...(parsed.absorb === "code" || parsed.absorb === "system"
116
+ ? { absorb: parsed.absorb }
117
+ : {}),
101
118
  };
102
119
  }
103
120
  catch {
104
121
  return DEFAULT_CONFIG;
105
122
  }
106
123
  }
124
+ /**
125
+ * A PRECEDÊNCIA, num lugar só: flag > config > default.
126
+ *
127
+ * E ela é IMPRESSA por quem chama, sempre. Estado persistido que não se anuncia é a etiqueta que
128
+ * envelhece em silêncio - o mesmo defeito que a tela de causas acabou de perder (dono, 06/08). Uma
129
+ * linha dizendo "migrate, definido em 06/08, --adopt inverte" transforma estado velho em fato.
130
+ *
131
+ * O default é `adopt` porque é o que uma pessoa com um sistema instalado quer na maioria dos dias, e
132
+ * porque é o único dos dois cuja lista tem conserto automático (`--fix`). Migrar é uma fase, e uma
133
+ * fase se declara.
134
+ */
135
+ export function intentOf(config, flag) {
136
+ if (flag === "migrate" || flag === "adopt")
137
+ return { intent: flag, from: "flag" };
138
+ if (config.intent)
139
+ return {
140
+ intent: config.intent,
141
+ from: "config",
142
+ ...(config.intentAt ? { at: config.intentAt } : {}),
143
+ };
144
+ return { intent: "adopt", from: "default" };
145
+ }
146
+ /** A frase que acompanha o relatório - ver `intentOf` para por que ela é obrigatória. */
147
+ export function describeIntent(source) {
148
+ const what = source.intent === "migrate"
149
+ ? "reading as a MIGRATION - values your system does not name yet come first"
150
+ : "reading as ADOPTION - values your system already names come first";
151
+ const where = source.from === "flag"
152
+ ? "this run only"
153
+ : source.from === "config"
154
+ ? `set in _synthesisui/config.json${source.at ? ` on ${source.at.slice(0, 10)}` : ""}`
155
+ : "nobody declared one, so this is the default";
156
+ const flip = source.intent === "migrate"
157
+ ? "`doctor --adopt` flips it"
158
+ : "`doctor --migrate` flips it";
159
+ return `${what} · ${where} · ${flip}`;
160
+ }
107
161
  /** Writes the project config (committable, plain JSON). */
108
162
  export async function writeProjectConfig(root, config) {
109
163
  const path = projectConfigPath(root);
@@ -19,7 +19,19 @@
19
19
  * actually differs between the two schemes; a role that is the same in both
20
20
  * costs nothing to spell either way, and this stays quiet about it.
21
21
  */
22
- const PRIMITIVE = /^\{color\.([a-z0-9]+)\.([a-z0-9]+)\}$/i;
22
+ /**
23
+ * A GRAMÁTICA DE UMA REF DE PRIMITIVA, e ela recusava hífen nos DOIS segmentos.
24
+ *
25
+ * Contada em 06/08: a mesma pergunta era feita por quatro regexes e as quatro discordavam - duas
26
+ * aceitavam hífen só na família, uma aceitava nos dois, e esta em nenhum. Consequência medida no repo do
27
+ * dono, que tem as famílias `royal-blue`, `vivid-pink` e `electric-indigo`: toda ref para elas parseava
28
+ * como nada aqui, e esta checagem passava calada por cima delas - um achado que não existe é
29
+ * indistinguível de um achado que não há.
30
+ *
31
+ * Gêmea de `PRIMITIVE_REF_PATTERN` em `libs/ds-contracts/src/token-ref.ts`, copiada e não importada
32
+ * porque o CLI é publicado standalone. O spec ao lado assere a fonte da regex nos dois lados.
33
+ */
34
+ const PRIMITIVE = /^\{color\.([a-z0-9-]+)\.([a-z0-9-]+)\}$/i;
23
35
  const BACKGROUND = /^background(-color)?$/;
24
36
  const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
25
37
  function* colorBindings(node, where) {
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
+ import { absorb } from "./commands/absorb.js";
3
4
  import { add } from "./commands/add.js";
4
5
  import { adopt } from "./commands/adopt.js";
5
6
  import { advise } from "./commands/advise.js";
@@ -19,6 +20,7 @@ import { mcp } from "./commands/mcp.js";
19
20
  import { refit } from "./commands/refit.js";
20
21
  import { request } from "./commands/request.js";
21
22
  import { status } from "./commands/status.js";
23
+ import { summary } from "./commands/summary.js";
22
24
  import { sync } from "./commands/sync.js";
23
25
  import { template } from "./commands/template.js";
24
26
  import { upgrade } from "./commands/upgrade.js";
@@ -99,6 +101,10 @@ Options:
99
101
  --strict doctor: exit 1 when drift is found in THIS repo (for CI)
100
102
  --strict-system doctor: also exit 1 when the system itself is inconsistent
101
103
  --verbose doctor: every finding, file by file (default is a summary)
104
+ --send absorb: send the batch you reviewed (default writes the file)
105
+ --migrate doctor: lead with what your system does NOT name yet - the
106
+ order for moving design INTO a shared package
107
+ --adopt doctor: lead with what your system already names (default)
102
108
  --laws doctor: show every usage law, not just the busiest components
103
109
  --out <path> output path for the generated template (default: <pagesDir>/<file>)
104
110
  -h, --help this help
@@ -279,9 +285,57 @@ async function main() {
279
285
  */
280
286
  ...(flags.write === true ? { write: true } : {}),
281
287
  ...(flags["write-baseline"] === true ? { writeBaseline: true } : {}),
288
+ /**
289
+ * A INTENÇÃO DESTA RODADA - atalho para `config.json`, ver `intentOf`.
290
+ *
291
+ * Duas flags e não um valor (`--intent=migrate`), porque o que se digita no meio de uma tarefa
292
+ * é uma palavra: `--migrate` ou `--adopt`.
293
+ */
294
+ ...(flags.migrate === true
295
+ ? { intent: "migrate" }
296
+ : flags.adopt === true
297
+ ? { intent: "adopt" }
298
+ : {}),
282
299
  cli: CLI_VERSION,
283
300
  });
284
301
  break;
302
+ case "absorb":
303
+ /**
304
+ * O OUTRO SENTIDO: o sistema aprende o design que o código já tem - ver `commands/absorb.ts`.
305
+ *
306
+ * Sem flag ESCREVE uma proposta e não fala com a rede; `--send` manda o lote. A fase do meio é
307
+ * humana de propósito: nomear uma cor é decisão de design.
308
+ */
309
+ await absorb({
310
+ dir,
311
+ registry,
312
+ ...(flags.send === true ? { send: true } : {}),
313
+ });
314
+ break;
315
+ case "summary": {
316
+ /**
317
+ * AS QUATRO LINHAS DO PRIMEIRO MINUTO - ver `commands/summary.ts`.
318
+ *
319
+ * Existe para tirar o fechamento do `sui-init` das mãos do agente: ele terminou com ~400 palavras
320
+ * sobre o próprio trabalho, incluindo duas advertências já desatualizadas (dono, 06/08).
321
+ */
322
+ /** `args` já exclui o comando - o slug é o primeiro posicional, como em `template <slug>`. */
323
+ const target = args[0];
324
+ if (!target) {
325
+ console.log("usage: synthesisui summary <slug> [--base N --draft N --fixes N]");
326
+ break;
327
+ }
328
+ const num = (v) => typeof v === "string" && /^\d+$/.test(v)
329
+ ? Number.parseInt(v, 10)
330
+ : undefined;
331
+ await summary(target, {
332
+ dir,
333
+ ...(num(flags.base) != null ? { base: num(flags.base) } : {}),
334
+ ...(num(flags.draft) != null ? { draft: num(flags.draft) } : {}),
335
+ ...(num(flags.fixes) != null ? { fixes: num(flags.fixes) } : {}),
336
+ });
337
+ break;
338
+ }
285
339
  case "status":
286
340
  /** A resposta do dia a dia no terminal - ver `commands/status.ts`. Local primeiro. */
287
341
  await status({ dir, registry, cli: CLI_VERSION });
@@ -338,11 +392,28 @@ async function main() {
338
392
  : undefined;
339
393
  const ds = typeof flags.ds === "string" ? flags.ds : undefined;
340
394
  const styles = typeof flags.styles === "string" ? flags.styles : undefined;
395
+ /**
396
+ * A INTENÇÃO, gravada com a data - ver `ProjectConfig.intent`.
397
+ *
398
+ * Aceita as duas formas porque as duas são naturais: `--intent migrate` quando um agente monta o
399
+ * comando, `--migrate` quando uma pessoa digita.
400
+ */
401
+ const intent = typeof flags.intent === "string"
402
+ ? flags.intent
403
+ : flags.migrate === true
404
+ ? "migrate"
405
+ : flags.adopt === true
406
+ ? "adopt"
407
+ : undefined;
341
408
  await init({
342
409
  dir,
343
410
  registry,
344
411
  target,
345
412
  pagesDir,
413
+ ...(flags.absorb === "code" || flags.absorb === "system"
414
+ ? { absorb: flags.absorb }
415
+ : {}),
416
+ ...(intent ? { intent } : {}),
346
417
  componentsDir,
347
418
  styles,
348
419
  ds,
@@ -0,0 +1,63 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ const EMPTY = { system: null, usage: [], from: "none" };
4
+ /** Existe como diretório? Um escopo gravado e depois renomeado não pode derrubar o comando. */
5
+ async function present(root, rel) {
6
+ return stat(resolve(root, rel)).then((s) => s.isDirectory(), () => false);
7
+ }
8
+ /**
9
+ * Lê `_synthesisui/census.json` e devolve o que ele diz sobre ONDE.
10
+ *
11
+ * Só caminhos que ainda existem: o censo é uma foto de quando o import rodou, e uma pasta renomeada
12
+ * desde então não pode fazer o doctor estourar - ela simplesmente sai da lista, e a lista vazia cai no
13
+ * comportamento antigo (a raiz).
14
+ */
15
+ export async function measuredScope(root) {
16
+ const raw = await readFile(join(root, "_synthesisui", "census.json"), "utf8").catch(() => null);
17
+ if (!raw)
18
+ return EMPTY;
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(raw);
22
+ }
23
+ catch {
24
+ return EMPTY;
25
+ }
26
+ const scope = typeof parsed.scope === "string" && parsed.scope.trim()
27
+ ? parsed.scope.trim()
28
+ : null;
29
+ const usage = Array.isArray(parsed.usage)
30
+ ? parsed.usage.filter((u) => typeof u === "string" && !!u.trim())
31
+ : [];
32
+ const system = scope && (await present(root, scope)) ? scope : null;
33
+ const kept = [];
34
+ for (const u of usage)
35
+ if (await present(root, u))
36
+ kept.push(u);
37
+ if (!system && kept.length === 0)
38
+ return EMPTY;
39
+ return { system, usage: kept, from: "census" };
40
+ }
41
+ /**
42
+ * As pastas a LER, na ordem em que importam - o sistema primeiro.
43
+ *
44
+ * A ordem não é cosmética: ela decide qual arquivo entra primeiro no relatório e, com teto de
45
+ * anotações no CI, decide o que chega ao PR.
46
+ */
47
+ export function scopePaths(m) {
48
+ return [...(m.system ? [m.system] : []), ...m.usage];
49
+ }
50
+ /** Como dizer o que foi lido. Uma leitura que não declara o universo é um número sem denominador. */
51
+ export function describeScope(m) {
52
+ if (m.from === "none")
53
+ return null;
54
+ const parts = [
55
+ ...(m.system ? [`${m.system} (the system)`] : []),
56
+ ...(m.usage.length > 0
57
+ ? [
58
+ `${m.usage.join(", ")} (${m.usage.length === 1 ? "consumer" : "consumers"})`,
59
+ ]
60
+ : []),
61
+ ];
62
+ return `read from your census: ${parts.join(" · ")}`;
63
+ }
@@ -32,20 +32,22 @@ visual, semantic and functional parity, without supposing and without inventing
32
32
 
33
33
  ## THE SHAPE OF THIS RUN, AND WHY IT IS THIS SHAPE
34
34
 
35
- Four questions, asked before anything that needs an answer. Then silence, then four lines.
35
+ Five questions, asked before anything that needs an answer. Then silence, then four lines.
36
36
 
37
37
  \`\`\`
38
38
  1 environment which agent they work in - and what that agent can actually get
39
39
  2 authorisation may we optimise after importing, and which parts
40
40
  3 project WHICH folder becomes the system (the only unavoidable choice)
41
41
  4 identity name, primary, theme - each already answered by the census
42
+ 5 intent adopting the system, or migrating design INTO it
43
+ (+ where absorbed values live, only if they are migrating)
42
44
  ---- nobody waits from here on ----
43
- import v1 -> freeze v1 -> apply what they authorised -> four lines
44
- 5 offer put the tokens back into their code
45
+ init -> import -> freeze -> apply what they authorised -> four lines
46
+ 6 offer the two lines that wire it up
45
47
  \`\`\`
46
48
 
47
49
  **Every question is asked before the work, and the work starts during the questions.** A person who
48
- answers four things in ninety seconds and then watches one bar has had a good four minutes; the same
50
+ answers five things in ninety seconds and then watches one bar has had a good four minutes; the same
49
51
  person interrupted twice mid-silence has had a bad twenty (dono, 01/08).
50
52
 
51
53
  **What runs in parallel, precisely.** The UNSCOPED census - \`npx synthesisui import --dry\` - needs no
@@ -161,6 +163,59 @@ in the \`sui-import-ds\` skill; do not reinvent it here, and do not ask anything
161
163
 
162
164
  ---
163
165
 
166
+ ## 5. INTENT
167
+
168
+ The one question the census cannot answer, and the only one whose answer changes what a later command
169
+ puts first.
170
+
171
+ \`\`\`
172
+ What are you doing with this system?
173
+
174
+ Adopting it (recommended) make the code use the system. The doctor leads with the
175
+ values your system already names - those have a one-line
176
+ fix, and \`doctor --fix\` does them.
177
+ Migrating into it you are moving design out of your apps and into the shared
178
+ package. The doctor leads with the values your system does
179
+ NOT name yet, by frequency - those are the tokens the
180
+ library still has to absorb.
181
+ \`\`\`
182
+
183
+ Both lists exist in both modes. The answer decides the ORDER, and the order decides what somebody
184
+ does first - measured on a real monorepo (dono, 06/08): with no intent declared, four of the five
185
+ suggestions came out of a consumer app, led by a colour that does not belong to the system's palette
186
+ at all. The same number is noise under one intent and the strongest candidate in the repo under the
187
+ other.
188
+
189
+ Pass the answer straight through:
190
+
191
+ \`\`\`
192
+ npx synthesisui@latest init --intent adopt (or --intent migrate)
193
+ \`\`\`
194
+
195
+ **One follow-up, and ONLY when they chose migrating.** Absorbing is what a migration does; asking an
196
+ adopter where absorbed values would go is asking about a command they will not run this month.
197
+
198
+ \`\`\`
199
+ When your system absorbs a value, where should it live?
200
+
201
+ In the system (recommended) it becomes a token of the design system, and the
202
+ tokens.css it generates starts emitting it
203
+ In your own CSS we hand you the block and you paste it into your
204
+ vocabulary; the next import reads it back as a
205
+ token you declared
206
+ \`\`\`
207
+
208
+ Then \`--absorb system\` or \`--absorb code\` on the same \`init\`. Both answers are real: the second exists
209
+ because in some teams the canonical vocabulary IS their stylesheet, and in that case our part is to hand
210
+ over the snippet - never to write in their file.
211
+
212
+ **Run \`init\` even when nothing else needs it.** It writes \`_synthesisui/config.json\` - the committed
213
+ file that holds \`target\`, \`pagesDir\`, \`componentsDir\`, \`styles\` and this intent - and until 06/08 this
214
+ run never called it: a full first run left no config at all, so \`template\` and \`component\` fell back to
215
+ defaults and the intent had nowhere to live (dono, 06/08).
216
+
217
+ ---
218
+
164
219
  ## THE SILENT PART
165
220
 
166
221
  Now nobody is asked anything.
@@ -209,46 +264,46 @@ answers 400, and a 400 in a first run reads as a broken product.
209
264
 
210
265
  ---
211
266
 
212
- ## 5. THE FOUR LINES
267
+ ## 6. THE FOUR LINES
213
268
 
214
- This is the whole point of the run. Four lines, their numbers, no adjectives.
269
+ This is the whole point of the run, and **it is not yours to compose.** Run this and print nothing
270
+ else:
215
271
 
216
272
  \`\`\`
217
- SignalUI is yours - 275 components and 92 of your own tokens, read out of packages/ui.
218
-
219
- v2 is frozen: your system exactly as your code has it today. It never moves.
220
- v3 is what you are looking at - the same system with 118 deterministic fixes: contrast on
221
- text only, lengths onto your own scale, near-identical colours unified.
222
-
223
- Back to v2 is one click: /dashboard/mine/signalui/publish
273
+ npx synthesisui@latest summary <slug> --base <base> --draft <draft> --fixes <how many>
224
274
  \`\`\`
225
275
 
226
- **THE VERSION NUMBERS COME FROM THE RESPONSE, NEVER FROM THIS EXAMPLE.** \`optimize\` answers with
227
- \`base\` and \`draft\`, and those are the two numbers to print. Writing "v1" here would have been wrong:
228
- a system already publishes its baseline at birth, so an import writes into v2 and the freeze produces
229
- v3. Proved against production before this skill ever ran (05/08) - and a first screen that points at
230
- the wrong version to restore is worse than no version at all.
276
+ It prints their component count, their own token count, where the system was read from, the two
277
+ versions with the way back, and the one thing to do next. The numbers for \`--base\` and \`--draft\` come
278
+ from the \`optimize\` RESPONSE - never invented here, because the platform is what numbers a version.
231
279
 
232
- **What must never be in those lines:** how many files we scanned, how long it took, which readers
233
- ran, what the ledger says. All of that is in the report and none of it is what somebody wants in the
234
- first minute of owning a system.
280
+ **Why a command instead of four lines you write.** The last screen of this run has ended long, more
281
+ than once - up to four hundred words, with three of its four paragraphs about the AGENT rather than
282
+ about their system: notes from a previous session, a generator that had been deleted and rebuilt, a
283
+ regression stripped by hand. Two of those notes were also out of date, which is worse than verbose: it
284
+ warned somebody about defects that no longer existed (dono, 06/08).
235
285
 
236
- **What must always be there:** the count of their components, the count of their own tokens, and the
237
- way back.
286
+ The pull to report your own work at the end of a long job beats a rule written sixty lines earlier. So
287
+ the rule became a program. What you have in hand when the run ends - your notes, your retries, your
288
+ repairs - has no way into a block that a command prints.
238
289
 
239
- ## 6. THE OFFER
290
+ **Anything you think belongs on that screen and is not in the command's output does not belong on that
291
+ screen.** If it is a real finding, it is already in the report or in the causes screen; if it is about
292
+ what you did, it is not theirs.
240
293
 
241
- One question, and only if there is something to offer - the doctor already knows the number.
294
+ ## 7. THE OFFER
242
295
 
243
- \`\`\`
244
- 340 hand-written values in your code have a token in your system now.
245
- Want me to replace them? It only touches values your system names, and you read the diff.
296
+ There is exactly one thing to offer, and it is not the fix: it is the two lines that wire the system
297
+ up. Until a stylesheet imports the tokens and an element carries \`data-ds\`, none of the tokens reach
298
+ the browser.
246
299
 
247
- npx synthesisui@latest doctor --fix
248
- \`\`\`
300
+ \`summary\` already prints both. Say nothing more about them.
249
301
 
250
- If the number is zero, say nothing. An offer with no subject is noise, and this run ends better
251
- short.
302
+ **Never offer \`doctor --fix\` here.** It was offered for a while, with the number of values that have a
303
+ token - and on an unwired project that swap points hundreds of literals at variables the browser
304
+ cannot resolve, so the declarations are dropped and the page changes (dono, 06/08: 210 values, in a
305
+ project where neither line was in place). The doctor itself now refuses in that state, and it is the
306
+ right place for the offer: it can see the wiring, and this run cannot see what they do after it ends.
252
307
 
253
308
  ---
254
309
 
@@ -261,6 +316,10 @@ short.
261
316
  - **Never applies an optimisation without a frozen v1**, and never reports one without the way back.
262
317
  - **Never claims parity between agents.** The post-write check exists in one of them. Saying
263
318
  otherwise is the kind of sentence that ends a trial.
264
- - **Never ends long.** If the last screen needs scrolling, the run failed at the last step.
319
+ - **Never ends long.** If the last screen needs scrolling, the run failed at the last step. The last
320
+ screen is \`summary\`'s output and nothing else - see section 6 for why that stopped being a rule and
321
+ became a command.
322
+ - **Never offers a fix that the wiring blocks.** A swap to tokens that do not reach the browser
323
+ changes the page, and it would be our command that changed it.
265
324
  `;
266
325
  export const INIT_SKILL_PATH = ".claude/skills/sui-init/SKILL.md";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.153",
3
+ "version": "0.16.154",
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": {