synthesisui 0.16.140 → 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.
- package/dist/agent-wiring.js +33 -3
- package/dist/claude-md.js +77 -27
- package/dist/commands/ci.js +84 -0
- package/dist/commands/connect.js +92 -18
- package/dist/commands/doctor.js +106 -2
- package/dist/commands/import.js +1 -1
- package/dist/commands/status.js +158 -0
- package/dist/commands/sync.js +145 -1
- package/dist/config.js +14 -0
- package/dist/doctor/apply-fix.js +161 -0
- package/dist/doctor/ci-format.js +205 -0
- package/dist/index.js +52 -5
- package/dist/skill-import.js +3 -3
- package/dist/skill-init.js +226 -0
- package/package.json +1 -1
|
@@ -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/commands/sync.js
CHANGED
|
@@ -3,8 +3,9 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { readToken, resolveRegistry } from "../config.js";
|
|
4
4
|
import { readEvents } from "../doctor/ledger.js";
|
|
5
5
|
import { checkableName, closeRequest, readRequests, verifyAndCloseRequests, } from "../doctor/requests.js";
|
|
6
|
-
import { body, section } from "../output.js";
|
|
6
|
+
import { body, paint, section } from "../output.js";
|
|
7
7
|
import { repoStateOf } from "../repo-state.js";
|
|
8
|
+
import { resolveReadParts, takeCensus } from "./import.js";
|
|
8
9
|
/**
|
|
9
10
|
* What each decision means ON THIS MACHINE - the card decides, the sync
|
|
10
11
|
* enacts. The web never touches the local file; this line is printed right
|
|
@@ -83,6 +84,19 @@ export async function sync(opts) {
|
|
|
83
84
|
console.log(body("No installed system here, so there is nowhere to sync to."));
|
|
84
85
|
return;
|
|
85
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* A MEDIÇÃO NOVA, ANTES DO REGISTRO - e é isto que faz um conserto de leitura alcançar alguém.
|
|
89
|
+
*
|
|
90
|
+
* Um leitor novo do CLI precisa OLHAR o repo: o que a plataforma guarda é o censo, e o fonte fica
|
|
91
|
+
* aqui por decisão de privacidade. Essa passada é irredutível. O que era redutível é a cerimônia -
|
|
92
|
+
* até hoje "re-importar" significava a skill inteira, seis perguntas, e um agente reescrevendo 261
|
|
93
|
+
* anatomias que já estavam gravadas (dono, 05/08).
|
|
94
|
+
*
|
|
95
|
+
* Então: a plataforma devolve o que já foi decidido (o escopo e a leitura), o CLI mede com o leitor
|
|
96
|
+
* ATUAL, funde a leitura no censo fresco, AVISA o que seria reescrito, e envia. Sem entrevista.
|
|
97
|
+
*/
|
|
98
|
+
if (!opts.recordOnly)
|
|
99
|
+
await remeasure({ root, base, token, slug, cli: opts.cli });
|
|
86
100
|
// Verification first, so the snapshot never reports "open" about something
|
|
87
101
|
// the installed css already delivers - a request closes because a machine
|
|
88
102
|
// CHECKED, not because someone remembered to say so (dono, 29/07).
|
|
@@ -152,3 +166,133 @@ export async function sync(opts) {
|
|
|
152
166
|
console.log(body(`The record lives on your system's overview:`));
|
|
153
167
|
console.log(body(` ${base}/dashboard/mine/${slug}`));
|
|
154
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* MEDE DE NOVO, AVISA, E ENVIA - o corpo do `sync` que faz um leitor novo chegar num sistema.
|
|
171
|
+
*
|
|
172
|
+
* A ordem é a lei aqui, e cada passo existe por um caso real:
|
|
173
|
+
*
|
|
174
|
+
* 1. a plataforma devolve o ESCOPO e a LEITURA já gravados. Sem isto, o `sync` teria que
|
|
175
|
+
* perguntar de novo o que já foi decidido, e um agente reescreveria 261 anatomias.
|
|
176
|
+
* 2. mede com o leitor ATUAL - é o único passo que precisa estar nesta máquina, porque o fonte
|
|
177
|
+
* dele não sai daqui.
|
|
178
|
+
* 3. funde a leitura gravada no censo fresco. A anatomia é do agente; a medição é nossa.
|
|
179
|
+
* 4. AVISA antes de enviar o que seria reescrito, e pergunta. Avisar depois de escrever não é
|
|
180
|
+
* avisar.
|
|
181
|
+
* 5. envia para a rota que atualiza o RASCUNHO pelo slug - nada publicado se move.
|
|
182
|
+
*/
|
|
183
|
+
async function remeasure(args) {
|
|
184
|
+
const { root, base, token, slug } = args;
|
|
185
|
+
const head = await fetch(`${base}/api/registry/ds/${slug}/reading`, {
|
|
186
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
187
|
+
}).catch(() => null);
|
|
188
|
+
if (!head?.ok) {
|
|
189
|
+
console.log(section("Sync"));
|
|
190
|
+
console.log(body(head
|
|
191
|
+
? `The registry answered ${head.status} for this system's reading, so nothing was re-measured. The record below still travels.`
|
|
192
|
+
: "Could not reach the registry, so nothing was re-measured. The record below still travels."));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const stored = (await head.json());
|
|
196
|
+
/**
|
|
197
|
+
* SEM CENSO GRAVADO NÃO HÁ O QUE REMEDIR CONTRA. Um sistema que nasceu da galeria não tem escopo
|
|
198
|
+
* nem leitura, e medir o repo dele produziria um censo de um projeto que ninguém mapeou para ele.
|
|
199
|
+
*/
|
|
200
|
+
if (!stored.hasCensus) {
|
|
201
|
+
console.log(section("Sync"));
|
|
202
|
+
console.log(body(`"${slug}" did not come from an import, so there is no measurement to refresh. Import it first: npx synthesisui import`));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
console.log(section("Measuring your repository again"));
|
|
206
|
+
console.log(body(paint.faint(`${stored.scope ? `scope ${stored.scope}` : "the whole repo"} · the reading you already authored travels unchanged`)));
|
|
207
|
+
const census = await takeCensus(stored.scope ? join(root, stored.scope) : root, {
|
|
208
|
+
...(stored.scope ? { scopeLabel: stored.scope } : {}),
|
|
209
|
+
...(args.cli ? { cli: args.cli } : {}),
|
|
210
|
+
/** O `--usage` é EVIDÊNCIA e leva rótulo: sem ele o censo não sabe de que app o número veio. */
|
|
211
|
+
usage: (stored.usage ?? []).map((u) => ({
|
|
212
|
+
path: join(root, u),
|
|
213
|
+
label: u,
|
|
214
|
+
})),
|
|
215
|
+
}).catch(() => null);
|
|
216
|
+
if (!census) {
|
|
217
|
+
console.log(body("The measurement failed, so nothing was sent."));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
/** A LEITURA GRAVADA ENTRA NO CENSO FRESCO - ver a rota `reading`. */
|
|
221
|
+
if (stored.reading)
|
|
222
|
+
census.reading = stored.reading;
|
|
223
|
+
await resolveReadParts(census, root).catch(() => { });
|
|
224
|
+
/**
|
|
225
|
+
* O AVISO, ANTES DE ENVIAR. `edited` são as receitas que uma pessoa escreveu na tela; o cruzamento
|
|
226
|
+
* com o que acabou de ser medido é uma interseção de conjuntos, e é por isso que ele mora aqui em
|
|
227
|
+
* vez de virar um gêmeo da regra que vive na plataforma.
|
|
228
|
+
*/
|
|
229
|
+
const slugOf = (name) => name
|
|
230
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
231
|
+
.toLowerCase()
|
|
232
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
233
|
+
.replace(/^-|-$/g, "");
|
|
234
|
+
const arriving = new Set(Object.keys(census.looks ?? {}).map(slugOf));
|
|
235
|
+
/**
|
|
236
|
+
* SÓ O QUE ESTE ENVIO TOCA. Uma receita que a pessoa editou e que a medição não traz fica onde
|
|
237
|
+
* está, e listá-la seria assustar sem motivo - o erro mais fácil de cometer neste aviso.
|
|
238
|
+
*/
|
|
239
|
+
const hit = (stored.draft?.edited ?? []).filter((e) => arriving.has(e.component));
|
|
240
|
+
if (hit.length > 0) {
|
|
241
|
+
console.log("");
|
|
242
|
+
console.log(section("You have an active draft"));
|
|
243
|
+
console.log(body(stored.draft?.warning ??
|
|
244
|
+
`${hit.length} of the ${stored.draft?.components ?? "?"} components in it were written on the screen, and this send would rewrite them.`));
|
|
245
|
+
console.log(body(paint.faint(hit
|
|
246
|
+
.slice(0, 6)
|
|
247
|
+
.map((e) => `${e.component}${e.at ? ` (${e.at.slice(0, 10)})` : ""}`)
|
|
248
|
+
.join(" · "))));
|
|
249
|
+
if (!(await askToOverwrite())) {
|
|
250
|
+
console.log(body("Nothing was sent. Your draft is untouched."));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const res = await fetch(`${base}/api/registry/ds/${slug}/census`, {
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: {
|
|
257
|
+
"content-type": "application/json",
|
|
258
|
+
Authorization: `Bearer ${token}`,
|
|
259
|
+
},
|
|
260
|
+
body: JSON.stringify({ census }),
|
|
261
|
+
}).catch(() => null);
|
|
262
|
+
if (!res?.ok) {
|
|
263
|
+
console.log(body(res
|
|
264
|
+
? `The registry answered ${res.status}. Nothing was written.`
|
|
265
|
+
: "Could not reach the registry. Nothing was written."));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const out = (await res.json());
|
|
269
|
+
console.log("");
|
|
270
|
+
console.log(section("Sent"));
|
|
271
|
+
console.log(body(`${out.written ?? 0} of ${out.total ?? 0} components written.`));
|
|
272
|
+
for (const note of out.notes ?? [])
|
|
273
|
+
console.log(body(paint.faint(note)));
|
|
274
|
+
if (out.url)
|
|
275
|
+
console.log(body(out.url));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Pergunta uma vez, e só num terminal de verdade.
|
|
279
|
+
*
|
|
280
|
+
* Sem TTY a resposta é NÃO: uma invocação por script que sobrescreve trabalho de alguém porque
|
|
281
|
+
* ninguém estava lá para dizer não é o pior default possível. `--yes` é o caminho explícito.
|
|
282
|
+
*/
|
|
283
|
+
async function askToOverwrite() {
|
|
284
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
285
|
+
return false;
|
|
286
|
+
const { createInterface } = await import("node:readline/promises");
|
|
287
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
288
|
+
try {
|
|
289
|
+
const answer = await rl.question(" send anyway? [y/N]: ");
|
|
290
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
rl.close();
|
|
297
|
+
}
|
|
298
|
+
}
|
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
|
+
}
|