synthesisui 0.16.153 → 0.16.155
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/absorb-plan.js +154 -0
- package/dist/commands/absorb.js +211 -0
- package/dist/commands/doctor.js +117 -14
- package/dist/commands/import.js +34 -0
- package/dist/commands/init.js +14 -0
- package/dist/commands/summary.js +79 -0
- package/dist/config.js +54 -0
- package/dist/doctor/frozen.js +13 -1
- package/dist/doctor/variant-read.js +86 -12
- package/dist/index.js +71 -0
- package/dist/measured-scope.js +63 -0
- package/dist/skill-init.js +92 -33
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
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 &&
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
...(
|
|
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))
|
package/dist/commands/import.js
CHANGED
|
@@ -857,6 +857,16 @@ export async function takeCensus(root, opts) {
|
|
|
857
857
|
...(v.unslotted.length > 0
|
|
858
858
|
? { unplaced: [...new Set(v.unslotted)] }
|
|
859
859
|
: {}),
|
|
860
|
+
/**
|
|
861
|
+
* AS NOTAS SÃO OUTRA LISTA - ver `notes` em `variant-read.ts`.
|
|
862
|
+
*
|
|
863
|
+
* Um layer que FICOU e precisa de um `data-*` não é um look sem lugar: ele está na receita.
|
|
864
|
+
* As duas viajavam em `unplaced`, e a tela de causas cobrava conserto de 16 linhas que
|
|
865
|
+
* diziam, no próprio texto, que nada tinha sido perdido (dono, 06/08).
|
|
866
|
+
*/
|
|
867
|
+
...(v.notes.length > 0
|
|
868
|
+
? { renderNotes: [...new Set(v.notes)] }
|
|
869
|
+
: {}),
|
|
860
870
|
...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
|
|
861
871
|
...(Object.keys(defaults).length > 0 ? { defaults } : {}),
|
|
862
872
|
};
|
|
@@ -1253,6 +1263,30 @@ export async function takeCensus(root, opts) {
|
|
|
1253
1263
|
console.log(body(paint.faint(` and ${unplacedByComponent.length - 8} more components`)));
|
|
1254
1264
|
}
|
|
1255
1265
|
}
|
|
1266
|
+
/**
|
|
1267
|
+
* E AS NOTAS DE RENDERIZAÇÃO, que são o oposto de uma perda.
|
|
1268
|
+
*
|
|
1269
|
+
* O layer ESTÁ na receita: o que falta é um atributo no elemento para ele aplicar. Até 06/08 estas
|
|
1270
|
+
* linhas viajavam junto com as de "não achei onde pôr" - 16 de 33 num repo real -, e a tela de causas
|
|
1271
|
+
* cobrava conserto de quem não tinha o que consertar. Separadas, elas viram o que sempre foram: uma
|
|
1272
|
+
* instrução para quem for renderizar.
|
|
1273
|
+
*/
|
|
1274
|
+
const notesByComponent = Object.entries(looks)
|
|
1275
|
+
.filter(([, look]) => (look.renderNotes ?? []).length > 0)
|
|
1276
|
+
.map(([component, look]) => ({
|
|
1277
|
+
component,
|
|
1278
|
+
lines: look.renderNotes ?? [],
|
|
1279
|
+
}));
|
|
1280
|
+
if (notesByComponent.length > 0) {
|
|
1281
|
+
const total = notesByComponent.reduce((n, u) => n + u.lines.length, 0);
|
|
1282
|
+
console.log("");
|
|
1283
|
+
console.log(section("Looks that are in the recipe and need an attribute"));
|
|
1284
|
+
console.log(body(`${total} decision${total === 1 ? "" : "s"} across ${notesByComponent.length} component${notesByComponent.length === 1 ? "" : "s"} were read and KEPT - they apply once the element carries the attribute the line names:`));
|
|
1285
|
+
for (const u of notesByComponent.slice(0, 6))
|
|
1286
|
+
console.log(body(paint.faint(` ${u.component}: ${u.lines[0]}`)));
|
|
1287
|
+
if (notesByComponent.length > 6)
|
|
1288
|
+
console.log(body(paint.faint(` and ${notesByComponent.length - 6} more components`)));
|
|
1289
|
+
}
|
|
1256
1290
|
/**
|
|
1257
1291
|
* THE DROPS, SAID OUT LOUD. `p-spacing-sm` on a real menu popup read as nothing and
|
|
1258
1292
|
* the popup shipped with no padding, invisibly - "if the compiler drops them, the drop
|
package/dist/commands/init.js
CHANGED
|
@@ -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) {
|