synthesisui 0.16.141 → 0.16.145

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,158 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { readToken, resolveRegistry } from "../config.js";
4
+ import { readEvents } from "../doctor/ledger.js";
5
+ import { readRequests } from "../doctor/requests.js";
6
+ import { body, paint, section, snippet } from "../output.js";
7
+ /**
8
+ * `synthesisui status` - a resposta do dia a dia, no terminal.
9
+ *
10
+ * A pergunta que um engenheiro faz sobre um sistema de design é sempre a mesma: em que pé estamos, e
11
+ * o que falta. Até 05/08 a resposta morava numa tela - cobertura, o que mudou, o que está pendente - e
12
+ * uma resposta que exige abrir o navegador é uma resposta que ninguém busca no meio de uma tarefa.
13
+ * Essa é a "terceira aba" que faz uma plataforma de governança ser ignorada, e ela era nossa.
14
+ *
15
+ * LOCAL PRIMEIRO, e a rede é opcional. Tudo que importa para decidir o próximo passo já está no disco:
16
+ * a versão instalada no `.lock`, o último doctor no registro, os pedidos abertos, o baseline commitado.
17
+ * A rede entra para UMA pergunta que o disco não sabe - "existe medição mais nova do que a minha?" - e
18
+ * a ausência dela nunca é um erro: sem login, sem rede, sem token, o resto continua respondido.
19
+ *
20
+ * E NÃO ESCREVE NADA. Nem ledger, nem cache, nem arquivo de estado: é uma leitura, e uma leitura que
21
+ * deixa rastro é a razão pela qual alguém desconfia de rodar um comando dentro de um repo alheio.
22
+ */
23
+ export async function status(opts) {
24
+ const root = resolve(opts.dir ?? process.cwd());
25
+ /** Todo sistema instalado aqui, com a versão que o `.lock` diz. */
26
+ const dsDir = join(root, "_synthesisui", "ds");
27
+ const installed = [];
28
+ try {
29
+ for (const entry of await readdir(dsDir, { withFileTypes: true })) {
30
+ if (!entry.isDirectory())
31
+ continue;
32
+ const raw = await readFile(join(dsDir, entry.name, ".lock"), "utf8").catch(() => "");
33
+ try {
34
+ const lock = JSON.parse(raw);
35
+ installed.push({
36
+ slug: lock.slug ?? entry.name,
37
+ ...(typeof lock.version === "number"
38
+ ? { version: lock.version }
39
+ : {}),
40
+ ...(lock.adopted ? { adopted: true } : {}),
41
+ });
42
+ }
43
+ catch {
44
+ installed.push({ slug: entry.name });
45
+ }
46
+ }
47
+ }
48
+ catch {
49
+ // sem _synthesisui: tratado abaixo
50
+ }
51
+ console.log(section("Status"));
52
+ if (installed.length === 0) {
53
+ console.log(body("No design system installed in this project, so there is nothing to be in sync with."));
54
+ console.log(paint.blue(snippet(["npx synthesisui@latest list"])));
55
+ return;
56
+ }
57
+ for (const ds of installed)
58
+ console.log(body(`${ds.slug}${ds.version ? ` · v${ds.version}` : ""}${ds.adopted ? " · adopted (described here, owned elsewhere)" : ""}`));
59
+ /**
60
+ * O ÚLTIMO DOCTOR, pelo mesmo leitor que o `sync` e o `repo-state` usam. Ler o formato do registro
61
+ * por conta própria aqui seria uma segunda implementação, e a primeira coisa que ela faria é
62
+ * discordar da outra sobre o que é uma linha válida.
63
+ */
64
+ const events = await readEvents(root);
65
+ const doctors = events
66
+ .filter((e) => e.kind === "doctor" && e.at)
67
+ .sort((a, b) => String(b.at).localeCompare(String(a.at)));
68
+ const last = doctors[0];
69
+ console.log("");
70
+ if (last) {
71
+ const age = ageOf(String(last.at));
72
+ console.log(body(`Last check ${age}${typeof last.coverage === "number" ? ` · token coverage ${last.coverage}%` : ""}${typeof last.named === "number" && last.named > 0 ? ` · ${last.named} hand-written values your system already names` : ""}`));
73
+ /**
74
+ * O NÚMERO ACIONÁVEL VEM COM O COMANDO QUE O RESOLVE. `named` é o que tem resposta pronta no
75
+ * sistema deles, e desde 05/08 existe uma coisa para fazer com ele em vez de encarar a lista.
76
+ */
77
+ if (typeof last.named === "number" && last.named > 0)
78
+ console.log(paint.blue(snippet(["npx synthesisui@latest doctor --fix"])));
79
+ }
80
+ else {
81
+ console.log(body("Nothing checked yet in this project - one command, no login:"));
82
+ console.log(paint.blue(snippet(["npx synthesisui@latest doctor"])));
83
+ }
84
+ /** A catraca: existe uma linha a manter, ou o CI ainda não tem o que comparar? */
85
+ const baseline = await readFile(join(root, "_synthesisui", "drift-baseline.json"), "utf8").catch(() => null);
86
+ if (baseline) {
87
+ try {
88
+ const parsed = JSON.parse(baseline);
89
+ console.log(body(`Ratchet at ${parsed.totals?.drift ?? "?"} findings, frozen ${parsed.at ? ageOf(parsed.at) : "at an unrecorded time"} - CI fails only above it.`));
90
+ }
91
+ catch {
92
+ console.log(body("A drift baseline exists but is not readable JSON."));
93
+ }
94
+ }
95
+ else {
96
+ console.log(body(paint.dim("No ratchet yet, so nothing in CI can fail on drift. `synthesisui ci` prints the two steps.")));
97
+ }
98
+ /**
99
+ * TODO REQUEST LIDO ESTÁ ABERTO: `closeRequest` REMOVE a linha do arquivo, então o arquivo é a fila
100
+ * e não um histórico. Filtrar por um campo de fechado aqui seria inventar um estado que o formato
101
+ * não tem.
102
+ */
103
+ const open = await readRequests(root);
104
+ if (open.length > 0) {
105
+ console.log("");
106
+ console.log(body(`${open.length} open request${open.length === 1 ? "" : "s"} the system has not answered yet:`));
107
+ for (const r of open.slice(0, 4))
108
+ console.log(body(paint.dim(` ${r.id} · ${r.name ?? r.kind}`)));
109
+ console.log(body(paint.dim("They travel on the next `synthesisui sync`.")));
110
+ }
111
+ /**
112
+ * A ÚNICA PERGUNTA QUE O DISCO NÃO SABE: existe medição mais nova do que a minha? Sem login isso
113
+ * simplesmente não é dito - e não é um erro, porque tudo acima continua verdadeiro.
114
+ */
115
+ const token = await readToken();
116
+ if (!token) {
117
+ console.log("");
118
+ console.log(body(paint.dim("Not logged in, so this is the local half. `synthesisui login` adds what the platform knows.")));
119
+ return;
120
+ }
121
+ const base = resolveRegistry(opts.registry);
122
+ const owned = installed.find((ds) => !ds.adopted);
123
+ if (!owned)
124
+ return;
125
+ const res = await fetch(`${base}/api/registry/ds/${owned.slug}/reading`, {
126
+ headers: { Authorization: `Bearer ${token}` },
127
+ }).catch(() => null);
128
+ if (!res?.ok) {
129
+ console.log("");
130
+ console.log(body(paint.dim(res
131
+ ? `The registry answered ${res.status} for ${owned.slug} - the local half above still holds.`
132
+ : "Could not reach the registry - the local half above still holds.")));
133
+ return;
134
+ }
135
+ const remote = (await res.json());
136
+ console.log("");
137
+ console.log(body(remote.hasCensus
138
+ ? `On the platform: ${remote.draft?.components ?? 0} components in the draft${remote.scope ? `, measured from ${remote.scope}` : ""}${(remote.draft?.edited?.length ?? 0) > 0 ? ` · ${remote.draft?.edited?.length} written on the screen` : ""}.`
139
+ : `${owned.slug} on the platform did not come from an import, so there is no measurement to compare.`));
140
+ if (remote.hasCensus)
141
+ console.log(body(paint.dim("`synthesisui sync` measures this repo again with today's reader and sends it.")));
142
+ }
143
+ /** "2 hours ago", sem dependência - e `Invalid Date` nunca chega à tela. */
144
+ function ageOf(iso) {
145
+ const then = Date.parse(iso);
146
+ if (Number.isNaN(then))
147
+ return "at an unrecorded time";
148
+ const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
149
+ if (mins < 2)
150
+ return "just now";
151
+ if (mins < 60)
152
+ return `${mins} minutes ago`;
153
+ const hours = Math.round(mins / 60);
154
+ if (hours < 24)
155
+ return `${hours} hour${hours === 1 ? "" : "s"} ago`;
156
+ const days = Math.round(hours / 24);
157
+ return `${days} day${days === 1 ? "" : "s"} ago`;
158
+ }
package/dist/config.js CHANGED
@@ -45,6 +45,20 @@ export function sameRegistry(a, b) {
45
45
  return norm(a) === norm(b);
46
46
  }
47
47
  export async function readToken() {
48
+ /**
49
+ * O AMBIENTE GANHA DO ARQUIVO, e é o que permite CI sem navegador.
50
+ *
51
+ * O `login` é device flow: abre um browser e precisa de uma pessoa. Isso é certo na primeira vez de
52
+ * alguém e é um beco sem saída num runner - e a checagem que interessa em CI (`doctor`) nem pede
53
+ * token, mas `sync` pede, e sem env var não existe caminho headless nenhum. Um segredo do runner é
54
+ * exatamente o lugar onde uma credencial de CI deve morar.
55
+ *
56
+ * A precedência é o ambiente porque é a mais específica: quem exporta a variável está dizendo
57
+ * "use esta agora", e um arquivo em `~/.synthesisui` não pode ganhar dessa intenção.
58
+ */
59
+ const fromEnv = process.env.SYNTHESISUI_TOKEN?.trim();
60
+ if (fromEnv)
61
+ return fromEnv;
48
62
  try {
49
63
  const raw = await readFile(credentialsPath, "utf8");
50
64
  const parsed = JSON.parse(raw);
@@ -0,0 +1,161 @@
1
+ /**
2
+ * O CONSERTO QUE O DOCTOR JÁ SABIA APONTAR - aplicado.
3
+ *
4
+ * O doctor sempre disse a frase certa: "`#4A90E2` está escrito à mão e o seu sistema já nomeia esse
5
+ * valor". E parava ali, o que numa lista de 1500 achados significa 1500 edições à mão que ninguém faz.
6
+ * A objeção de um Design System Lead cético é exatamente esta: governança que aponta o problema e não
7
+ * carrega o conserto é auditoria, e engenheiro não adota auditoria.
8
+ *
9
+ * SÓ O QUE TEM TOKEN. Um achado sem token é uma decisão de design - nomear um valor novo é uma
10
+ * conversa, e trocar `#ff0000` por um nome inventado por nós seria a pior coisa que esta esteira pode
11
+ * fazer. `--fix` só toca o caso em que a resposta já existe no sistema DELES.
12
+ *
13
+ * E é aqui que ele se aplica, e não num codemod solto: a troca é `literal` → `var(--token)` na LINHA
14
+ * que o scanner já mediu, com o literal exato que ele já leu. Nada de regex sobre o arquivo inteiro,
15
+ * nada de AST: o achado carrega arquivo, linha e literal, então a edição é cirúrgica por construção.
16
+ *
17
+ * TRÊS COISAS QUE ELE SE RECUSA A FAZER, e cada uma é um jeito conhecido de um codemod destruir
18
+ * confiança:
19
+ *
20
+ * 1. tocar uma linha que mudou desde a medição - se o texto não bate, pula e conta
21
+ * 2. trocar mais de uma ocorrência por achado - dois `#fff` na mesma linha são dois achados,
22
+ * e cada um tem a sua própria linha no relatório
23
+ * 3. escrever em arquivo que o scanner marcou como fora (gerado, vendored) - esses nunca
24
+ * chegam aqui, porque o walk já os pula
25
+ */
26
+ import { readFile, writeFile } from "node:fs/promises";
27
+ import { join } from "node:path";
28
+ /**
29
+ * A substituição numa linha só, e ela é deliberadamente burra.
30
+ *
31
+ * `indexOf` do literal exato, primeira ocorrência, nada mais. Um literal de cor ou de comprimento não
32
+ * tem sintaxe ambígua o suficiente para justificar um parser aqui, e um parser que erra num arquivo de
33
+ * outra pessoa custa mais que os achados que ele conserta.
34
+ */
35
+ function swap(line, literal, token) {
36
+ const at = line.indexOf(literal);
37
+ if (at === -1)
38
+ return null;
39
+ return `${line.slice(0, at)}var(${token})${line.slice(at + literal.length)}`;
40
+ }
41
+ /**
42
+ * Aplica o que tem token, em memória, e devolve o conteúdo novo por arquivo.
43
+ *
44
+ * Pura de propósito - quem escreve em disco é o comando. Um spec precisa poder afirmar a lei sem um
45
+ * diretório temporário, e o `--dry` sai de graça: o mesmo cálculo, sem a escrita.
46
+ */
47
+ export function planFix(d, read) {
48
+ const applied = [];
49
+ const skipped = [];
50
+ const next = new Map();
51
+ /** Linhas por arquivo, mutadas em memória: dois achados no mesmo arquivo compõem. */
52
+ const lines = new Map();
53
+ /**
54
+ * DE BAIXO PARA CIMA no arquivo não é necessário - a troca não muda a contagem de linhas - mas a
55
+ * ORDEM por arquivo é, para dois achados na mesma linha não se atropelarem: o segundo procura o seu
56
+ * literal no texto que o primeiro já deixou.
57
+ */
58
+ for (const f of d.findings) {
59
+ if (!f.token) {
60
+ skipped.push({
61
+ file: f.file,
62
+ line: f.line,
63
+ literal: f.literal,
64
+ because: "no-token",
65
+ });
66
+ continue;
67
+ }
68
+ if (!lines.has(f.file)) {
69
+ const raw = read(f.file);
70
+ if (raw === null) {
71
+ skipped.push({
72
+ file: f.file,
73
+ line: f.line,
74
+ literal: f.literal,
75
+ because: "unreadable",
76
+ });
77
+ continue;
78
+ }
79
+ lines.set(f.file, raw.split("\n"));
80
+ }
81
+ const body = lines.get(f.file);
82
+ if (!body)
83
+ continue;
84
+ const idx = f.line - 1;
85
+ const current = body[idx];
86
+ const swapped = current === undefined ? null : swap(current, f.literal, f.token);
87
+ if (swapped === null) {
88
+ /** O arquivo mudou desde a medição, ou o literal já foi trocado por outro achado. */
89
+ skipped.push({
90
+ file: f.file,
91
+ line: f.line,
92
+ literal: f.literal,
93
+ because: "moved",
94
+ });
95
+ continue;
96
+ }
97
+ body[idx] = swapped;
98
+ applied.push({
99
+ file: f.file,
100
+ line: f.line,
101
+ literal: f.literal,
102
+ token: f.token,
103
+ });
104
+ }
105
+ for (const [file, body] of lines) {
106
+ const touched = applied.some((a) => a.file === file);
107
+ if (touched)
108
+ next.set(file, body.join("\n"));
109
+ }
110
+ return {
111
+ result: { applied, skipped, files: next.size },
112
+ next,
113
+ };
114
+ }
115
+ /** Escreve o que o plano decidiu. Nada aqui decide nada. */
116
+ export async function writeFix(root, next) {
117
+ for (const [file, content] of next)
118
+ await writeFile(join(root, file), content, "utf8");
119
+ }
120
+ /** O leitor síncrono que o plano usa, para o comando não ter que carregar tudo antes. */
121
+ export async function readerFor(root, files) {
122
+ const cache = new Map();
123
+ for (const file of new Set(files))
124
+ cache.set(file, await readFile(join(root, file), "utf8").catch(() => null));
125
+ return (file) => cache.get(file) ?? null;
126
+ }
127
+ /**
128
+ * O que aconteceu, em texto, e ele lidera pelo número que importa.
129
+ *
130
+ * `no-token` NÃO é uma falha e não aparece como uma: é o resto do trabalho, e é uma conversa de
131
+ * design. Chamar de "pulado" o que é "não é seu para decidir" seria treinar alguém a rodar `--fix` e
132
+ * achar que terminou.
133
+ */
134
+ export function describeFix(result, dry) {
135
+ const { applied, skipped } = result;
136
+ const moved = skipped.filter((s) => s.because === "moved").length;
137
+ const unread = skipped.filter((s) => s.because === "unreadable").length;
138
+ const decisions = skipped.filter((s) => s.because === "no-token").length;
139
+ const lines = [];
140
+ if (applied.length === 0) {
141
+ lines.push(decisions > 0
142
+ ? `Nothing to apply. All ${decisions} findings are values your system has no name for - those are design decisions, not fixes.`
143
+ : "Nothing to apply.");
144
+ return lines;
145
+ }
146
+ lines.push(`${dry ? "Would replace" : "Replaced"} ${applied.length} hand-written value${applied.length === 1 ? "" : "s"} with the token your system already has, across ${result.files} file${result.files === 1 ? "" : "s"}.`);
147
+ const byToken = new Map();
148
+ for (const a of applied)
149
+ byToken.set(a.token, (byToken.get(a.token) ?? 0) + 1);
150
+ for (const [token, n] of [...byToken.entries()]
151
+ .sort((a, b) => b[1] - a[1])
152
+ .slice(0, 6))
153
+ lines.push(` var(${token}) · ${n} time${n === 1 ? "" : "s"}`);
154
+ if (moved > 0)
155
+ lines.push(`${moved} line${moved === 1 ? "" : "s"} changed since the scan and ${moved === 1 ? "was" : "were"} left alone - run it again.`);
156
+ if (unread > 0)
157
+ lines.push(`${unread} file${unread === 1 ? "" : "s"} could not be read.`);
158
+ if (decisions > 0)
159
+ lines.push(`${decisions} finding${decisions === 1 ? "" : "s"} left: values your system has no name for. Those are decisions - name them, or file a request: npx synthesisui request token`);
160
+ return lines;
161
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * O DOCTOR NA PIPELINE - anotação na linha, e uma CATRACA em vez de um pedágio.
3
+ *
4
+ * A objeção mais dura que um Design System Lead cético faz a qualquer plataforma de governança é que
5
+ * ela vive numa terceira aba: se a checagem não está no PR, no linter, no autocomplete, o engenheiro
6
+ * ignora. O doctor já respondia metade disso - roda offline, sem login, em 1,8s sobre 2797 arquivos -
7
+ * e não tinha como falar a língua de um PR.
8
+ *
9
+ * O QUE FALTAVA MAIS QUE O FORMATO, e é o achado que decidiu esta peça: o doctor só reprova com
10
+ * `--strict`, e `--strict` num repo legado é vermelho no dia um. Um portão que fica vermelho para
11
+ * sempre é um portão que alguém contorna na segunda semana - é literalmente o "pedágio" que faz times
12
+ * criarem biblioteca paralela. Então o que entra na pipeline não é um portão: é uma CATRACA.
13
+ *
14
+ * sem baseline nunca reprova, e anota tudo como aviso
15
+ * com baseline reprova SÓ quando a conta subiu - no total ou em algum arquivo
16
+ * --write-baseline congela onde vocês estão hoje, e a dívida de ontem para de doer
17
+ *
18
+ * Um arquivo que piorou reprova mesmo quando o total caiu, de propósito: num monorepo o total esconde
19
+ * um time melhorando e outro regredindo, e a regressão é a única coisa que uma catraca existe para
20
+ * pegar.
21
+ *
22
+ * NADA AQUI FALA COM A REDE nem pede login. É a mesma decisão que mantém o hook instalado: uma
23
+ * checagem que telefona para casa é uma checagem que alguém desinstala.
24
+ */
25
+ const byFileOf = (d) => {
26
+ const out = {};
27
+ for (const f of d.files)
28
+ if (f.findings.length > 0)
29
+ out[f.file] = f.findings.length;
30
+ return out;
31
+ };
32
+ /** O retrato de agora, para ser gravado e comparado depois. */
33
+ export function baselineOf(d, meta) {
34
+ return {
35
+ baseline: 1,
36
+ at: meta.at,
37
+ ...(meta.cli ? { cli: meta.cli } : {}),
38
+ ...(meta.scope ? { scope: meta.scope } : {}),
39
+ totals: { drift: d.findings.length, phantoms: d.phantomUses },
40
+ byFile: byFileOf(d),
41
+ };
42
+ }
43
+ /**
44
+ * A conta subiu?
45
+ *
46
+ * `incomparable` existe porque comparar dois escopos diferentes produz uma regressão inventada - o
47
+ * dono mediu `packages/ui` e `apps/web-dashboard` no mesmo dia, e um deles tem 2,5x os arquivos do
48
+ * outro. Nesse caso a catraca NÃO reprova e diz por quê: reprovar por diferença de input é o alarme
49
+ * falso que ensina um time a passar `--no-verify`.
50
+ */
51
+ export function compareToBaseline(d, base, now) {
52
+ const mine = byFileOf(d);
53
+ const regressed = [];
54
+ let improved = 0;
55
+ for (const [file, count] of Object.entries(mine)) {
56
+ const was = base.byFile[file] ?? 0;
57
+ if (count > was)
58
+ regressed.push({ file, was, now: count });
59
+ }
60
+ for (const [file, was] of Object.entries(base.byFile))
61
+ if ((mine[file] ?? 0) < was)
62
+ improved += 1;
63
+ const drift = { was: base.totals.drift, now: d.findings.length };
64
+ const phantoms = { was: base.totals.phantoms, now: d.phantomUses };
65
+ const scopeChanged = (base.scope ?? "") !== (now.scope ?? "");
66
+ return {
67
+ worse: !scopeChanged &&
68
+ (drift.now > drift.was ||
69
+ phantoms.now > phantoms.was ||
70
+ regressed.length > 0),
71
+ drift,
72
+ phantoms,
73
+ regressed: regressed.sort((a, b) => b.now - b.was - (a.now - a.was)),
74
+ improved,
75
+ ...(scopeChanged
76
+ ? {
77
+ incomparable: `the baseline was measured on ${base.scope ?? "the whole repo"} and this run read ${now.scope ?? "the whole repo"} - different inputs, so nothing is failed on the difference`,
78
+ }
79
+ : {}),
80
+ };
81
+ }
82
+ /** O que a mensagem de uma anotação diz, e é a mesma frase nos dois formatos. */
83
+ function messageFor(f) {
84
+ return f.token
85
+ ? `${f.literal} is written by hand, and this project's system already names it: var(${f.token}). Use the token.`
86
+ : `${f.literal} is a ${f.kind} value written by hand, and the system has no name for it yet. Name it, or file a token request: npx synthesisui request token`;
87
+ }
88
+ /**
89
+ * ANOTAÇÕES DO GITHUB, que aparecem na linha exata do diff.
90
+ *
91
+ * Nenhuma Action é necessária: o GitHub lê estes comandos do stdout de qualquer step, então a
92
+ * integração inteira é uma linha no workflow deles. Uma Action publicada seria uma peça a mais para
93
+ * manter oferecendo exactamente isto.
94
+ *
95
+ * TUDO É `warning`, e nunca `error`: a severidade que reprova é o exit code, e ela é decidida pela
96
+ * catraca. Anotação vermelha em cima de dívida herdada é o mesmo pedágio de sempre, pintado de outra
97
+ * cor.
98
+ */
99
+ export function githubAnnotations(d, cap = 200) {
100
+ const out = [];
101
+ /**
102
+ * O QUE TEM CONSERTO ÓBVIO VEM PRIMEIRO, porque o teto é real e ele decide o que chega ao PR.
103
+ *
104
+ * Um achado cujo token JÁ EXISTE é uma troca de uma linha; um valor que o sistema não nomeia é uma
105
+ * decisão de design. Ordenar assim faz o teto cortar a conversa difícil em vez do conserto fácil -
106
+ * e medido no repo do dono, sem ordem nenhuma as 200 anotações saíram todas de um diretório gerado.
107
+ */
108
+ const ranked = [...d.findings].sort((a, b) => a.token && !b.token ? -1 : b.token && !a.token ? 1 : 0);
109
+ for (const f of ranked.slice(0, cap)) {
110
+ const title = f.token
111
+ ? "Token exists for this value"
112
+ : "Design value not in the system";
113
+ /** `::` e `%` quebram o parser do GitHub, e uma anotação que não parseia desaparece calada. */
114
+ const clean = (s) => s
115
+ .replace(/%/g, "%25")
116
+ .replace(/\r/g, "%0D")
117
+ .replace(/\n/g, "%0A")
118
+ .replace(/::/g, ":");
119
+ out.push(`::warning file=${f.file},line=${f.line},title=${clean(title)}::${clean(messageFor(f))}`);
120
+ }
121
+ if (d.findings.length > cap)
122
+ out.push(`::notice::${d.findings.length - cap} more findings are in the report - annotations are capped at ${cap} so the log stays readable. Run \`npx synthesisui doctor --verbose\` locally for all of them.`);
123
+ return out;
124
+ }
125
+ /**
126
+ * SARIF 2.1.0, para quem já tem um pipeline de qualidade de código.
127
+ *
128
+ * É o formato que o code-scanning do GitHub, o SonarQube e as ferramentas de segurança já ingerem -
129
+ * então o achado entra na mesma caixa de entrada em que o time já olha, em vez de exigir uma nova.
130
+ * Mínimo e válido: `runs[].tool`, `results[]` com `ruleId`, `level`, `message`, `locations`.
131
+ */
132
+ export function sarifOf(d, cli) {
133
+ const rules = [
134
+ {
135
+ id: "synthesisui/token-exists",
136
+ shortDescription: {
137
+ text: "A design value written by hand that the system already names",
138
+ },
139
+ help: {
140
+ text: "The project's own design system declares a token with this exact value. Using the literal means a later change to the token will not reach this line.",
141
+ },
142
+ },
143
+ {
144
+ id: "synthesisui/unnamed-value",
145
+ shortDescription: {
146
+ text: "A design value the system has no name for",
147
+ },
148
+ help: {
149
+ text: "Nothing in the system holds this value. Either it belongs in the system - file a token request - or it is deliberately one-off, which is a decision worth writing down.",
150
+ },
151
+ },
152
+ ];
153
+ return JSON.stringify({
154
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
155
+ version: "2.1.0",
156
+ runs: [
157
+ {
158
+ tool: {
159
+ driver: {
160
+ name: "SynthesisUI doctor",
161
+ ...(cli ? { version: cli } : {}),
162
+ informationUri: "https://www.synthesisui.com",
163
+ rules,
164
+ },
165
+ },
166
+ results: d.findings.map((f) => ({
167
+ ruleId: f.token
168
+ ? "synthesisui/token-exists"
169
+ : "synthesisui/unnamed-value",
170
+ /** `warning` sempre: quem reprova é a catraca, não o achado. */
171
+ level: "warning",
172
+ message: { text: messageFor(f) },
173
+ locations: [
174
+ {
175
+ physicalLocation: {
176
+ artifactLocation: { uri: f.file },
177
+ region: { startLine: f.line, snippet: { text: f.excerpt } },
178
+ },
179
+ },
180
+ ],
181
+ })),
182
+ },
183
+ ],
184
+ }, null, 1);
185
+ }
186
+ /** O veredito da catraca em texto, para o log do CI - e ele lidera pela boa notícia. */
187
+ export function describeVerdict(v) {
188
+ const lines = [];
189
+ if (v.incomparable) {
190
+ lines.push(`Baseline not comparable: ${v.incomparable}.`);
191
+ return lines;
192
+ }
193
+ lines.push(`Drift ${v.drift.was} → ${v.drift.now}${v.phantoms.was || v.phantoms.now ? ` · phantom tokens ${v.phantoms.was} → ${v.phantoms.now}` : ""}.`);
194
+ if (v.improved > 0)
195
+ lines.push(`${v.improved} file${v.improved === 1 ? "" : "s"} improved since the baseline.`);
196
+ if (v.regressed.length === 0) {
197
+ lines.push("Nothing regressed. The ratchet holds.");
198
+ return lines;
199
+ }
200
+ lines.push(`${v.regressed.length} file${v.regressed.length === 1 ? "" : "s"} got worse than the baseline:`);
201
+ for (const r of v.regressed.slice(0, 10))
202
+ lines.push(` ${r.file}: ${r.was} → ${r.now}`);
203
+ lines.push("Fix those lines, or accept them into the baseline on purpose: npx synthesisui doctor --write-baseline");
204
+ return lines;
205
+ }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { add } from "./commands/add.js";
4
4
  import { adopt } from "./commands/adopt.js";
5
5
  import { advise } from "./commands/advise.js";
6
+ import { ci } from "./commands/ci.js";
6
7
  import { clean } from "./commands/clean.js";
7
8
  import { component } from "./commands/component.js";
8
9
  import { connect } from "./commands/connect.js";
@@ -17,6 +18,7 @@ import { login } from "./commands/login.js";
17
18
  import { mcp } from "./commands/mcp.js";
18
19
  import { refit } from "./commands/refit.js";
19
20
  import { request } from "./commands/request.js";
21
+ import { status } from "./commands/status.js";
20
22
  import { sync } from "./commands/sync.js";
21
23
  import { template } from "./commands/template.js";
22
24
  import { upgrade } from "./commands/upgrade.js";
@@ -44,7 +46,7 @@ Usage - governance (deterministic, FREE):
44
46
  synthesisui adopt [--write] turn the design system you ALREADY have into a contract
45
47
  synthesisui import [--scope <p>] [--usage <p>] read what you already have and make it a system
46
48
  your agent follows - without touching your CSS
47
- synthesisui connect wire your agent: the check as an editor hook, the system
49
+ synthesisui connect [--ci] wire your agent: the check as an editor hook, the system
48
50
  as MCP tools, and a contract that stops repeating itself
49
51
  synthesisui doctor [paths…] [--verbose] audit for DRIFT: every design value written by hand, the
50
52
  token your system already has for it, coherence, and the
@@ -58,6 +60,12 @@ Usage - governance (deterministic, FREE):
58
60
  reusing the reading you already authored, plus the local
59
61
  record - checks, fixes, open requests (--record-only skips
60
62
  the measurement, --yes skips the overwrite question)
63
+ synthesisui doctor --fix [--dry] replace every hand-written value your system already
64
+ names with the token, in place
65
+ synthesisui status where this project stands: installed version, last check,
66
+ the ratchet, open requests - local first, no login needed
67
+ synthesisui ci [--write] the two steps that put drift in your PR - annotations on
68
+ the line, and a ratchet that fails only on regression
61
69
  synthesisui hook the check itself; installed by connect, run by your editor
62
70
  synthesisui mcp the system as tools; installed by connect, run by your agent
63
71
 
@@ -229,6 +237,7 @@ async function main() {
229
237
  version: CLI_VERSION,
230
238
  hook: flags.hook !== false,
231
239
  mcp: flags.mcp !== false,
240
+ ci: flags.ci === true,
232
241
  });
233
242
  return;
234
243
  case "hook":
@@ -252,8 +261,31 @@ async function main() {
252
261
  // written last week does not break.
253
262
  verbose: flags.verbose === true || flags.all === true,
254
263
  laws: flags.laws === true,
264
+ /**
265
+ * A PIPELINE - ver `ci-format.ts`. `--format` decide a língua, `--baseline` decide o que
266
+ * reprova: sem ele nada reprova por dívida herdada, com ele reprova só a regressão.
267
+ */
268
+ ...(flags.format === "github" || flags.format === "sarif"
269
+ ? { format: flags.format }
270
+ : {}),
271
+ ...(typeof flags.baseline === "string"
272
+ ? { baseline: flags.baseline }
273
+ : {}),
274
+ ...(flags.baseline === true ? { compareBaseline: true } : {}),
275
+ ...(flags.fix === true ? { fix: true } : {}),
276
+ ...(flags.dry === true ? { dry: true } : {}),
277
+ ...(flags["write-baseline"] === true ? { writeBaseline: true } : {}),
278
+ cli: CLI_VERSION,
255
279
  });
256
280
  break;
281
+ case "status":
282
+ /** A resposta do dia a dia no terminal - ver `commands/status.ts`. Local primeiro. */
283
+ await status({ dir, registry, cli: CLI_VERSION });
284
+ break;
285
+ case "ci":
286
+ /** A governança dentro do PR - ver `commands/ci.ts`. Imprime, ou grava com `--write`. */
287
+ await ci({ dir, write: flags.write === true });
288
+ break;
257
289
  case "gaps":
258
290
  /**
259
291
  * O LEDGER JÁ ESTÁ NO CENSO - isto não mede nada, tria.
@@ -11,8 +11,8 @@
11
11
  * CLI no longer has.
12
12
  */
13
13
  export const IMPORT_SKILL = `---
14
- name: import-design-system
15
- description: Turn a codebase the user ALREADY has into a SynthesisUI design system. Use when someone points at an existing repo, app or component library and asks to import it, adopt it, bring it in, or "make a design system from this" (e.g. "/import-design-system", "importa o meu packages/ui", "turn this app into a design system"). Drives the full pipeline - census → your reading → import → v2 proposal - and answers the questions arithmetic cannot.
14
+ name: sui-import-ds
15
+ description: Turn a codebase the user ALREADY has into a SynthesisUI design system. Use when someone points at an existing repo, app or component library and asks to import it, adopt it, bring it in, or "make a design system from this" (e.g. "/sui-import-ds", "importa o meu packages/ui", "turn this app into a design system"). Drives the full pipeline - census → your reading → import → v2 proposal - and answers the questions arithmetic cannot.
16
16
  ---
17
17
 
18
18
  # Import Design System
@@ -1287,4 +1287,4 @@ Say so if it comes up, rather than implying otherwise:
1287
1287
  link and check.
1288
1288
  `;
1289
1289
  /** Where it lands in the consumer's repo. */
1290
- export const IMPORT_SKILL_PATH = ".claude/skills/import-design-system/SKILL.md";
1290
+ export const IMPORT_SKILL_PATH = ".claude/skills/sui-import-ds/SKILL.md";