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.
- 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/status.js +158 -0
- 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 +33 -1
- package/dist/skill-import.js +3 -3
- package/dist/skill-init.js +226 -0
- package/package.json +1 -1
package/dist/agent-wiring.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
const HOOK_MATCHER = "Write|Edit|MultiEdit";
|
|
4
4
|
const exists = (p) => access(p).then(() => true, () => false);
|
|
5
5
|
async function readJson(path) {
|
|
@@ -67,13 +67,26 @@ async function wireHook(root, command) {
|
|
|
67
67
|
await writeFile(path, `${JSON.stringify({ ...settings, hooks: { ...hooks, PostToolUse: post } }, null, 2)}\n`, "utf8");
|
|
68
68
|
return { status: "added", command };
|
|
69
69
|
}
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* O MESMO SERVIDOR MCP, NOS CAMINHOS QUE CADA AGENTE LÊ.
|
|
72
|
+
*
|
|
73
|
+
* `.mcp.json` é o escopo-de-projeto do Claude Code; o Cursor lê `.cursor/mcp.json` com o MESMO shape
|
|
74
|
+
* (`mcpServers`). Então cobrir os dois é o mesmo JSON num segundo caminho, e as 11 ferramentas passam
|
|
75
|
+
* a existir nos dois editores sem uma linha de motor novo - era a lacuna mais barata da análise de DX
|
|
76
|
+
* (05/08).
|
|
77
|
+
*
|
|
78
|
+
* O SEGUNDO SÓ SE A PASTA JÁ EXISTIR. Criar `.cursor/` num repo de quem usa Claude Code é escrever
|
|
79
|
+
* arquivo de um editor que a pessoa não usa, e é a primeira coisa que alguém apaga com raiva - a
|
|
80
|
+
* mesma regra que já vale para o `AGENTS.md`.
|
|
81
|
+
*/
|
|
82
|
+
async function wireMcpAt(root, version, rel) {
|
|
83
|
+
const path = join(root, rel);
|
|
72
84
|
const config = await readJson(path);
|
|
73
85
|
const servers = (config.mcpServers ?? {});
|
|
74
86
|
if (servers.synthesisui)
|
|
75
87
|
return "already there";
|
|
76
88
|
const local = await exists(join(root, "node_modules", ".bin", "synthesisui"));
|
|
89
|
+
await mkdir(dirname(path), { recursive: true }).catch(() => { });
|
|
77
90
|
await writeFile(path, `${JSON.stringify({
|
|
78
91
|
...config,
|
|
79
92
|
mcpServers: {
|
|
@@ -89,6 +102,23 @@ async function wireMcp(root, version) {
|
|
|
89
102
|
}, null, 2)}\n`, "utf8");
|
|
90
103
|
return "added";
|
|
91
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Os caminhos de MCP que este repo merece: sempre o do Claude Code, e o do Cursor quando `.cursor/`
|
|
107
|
+
* já existe. Devolve o que foi escrito, por caminho, para o `connect` poder dizer os nomes.
|
|
108
|
+
*/
|
|
109
|
+
async function wireMcp(root, version) {
|
|
110
|
+
const wrote = [];
|
|
111
|
+
wrote.push({
|
|
112
|
+
path: ".mcp.json",
|
|
113
|
+
status: await wireMcpAt(root, version, ".mcp.json"),
|
|
114
|
+
});
|
|
115
|
+
if (await exists(join(root, ".cursor")))
|
|
116
|
+
wrote.push({
|
|
117
|
+
path: ".cursor/mcp.json",
|
|
118
|
+
status: await wireMcpAt(root, version, ".cursor/mcp.json"),
|
|
119
|
+
});
|
|
120
|
+
return wrote;
|
|
121
|
+
}
|
|
92
122
|
export async function wireAgent(root, version, want) {
|
|
93
123
|
const proposed = await hookCommand(root, version);
|
|
94
124
|
const hook = want.hook
|
package/dist/claude-md.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { hasHook } from "./agent-wiring.js";
|
|
4
4
|
const START = "<!-- synthesisui:start -->";
|
|
5
5
|
const END = "<!-- synthesisui:end -->";
|
|
@@ -340,34 +340,84 @@ _Block managed by the CLI - do not edit by hand; run \`synthesisui ${onlyAdopted
|
|
|
340
340
|
* DS. Idempotent: replaces the text between the markers if present, otherwise
|
|
341
341
|
* creates the file / appends the block. Returns whether the file was created.
|
|
342
342
|
*/
|
|
343
|
+
/**
|
|
344
|
+
* ONDE O BLOCO GERENCIADO MORA, POR AGENTE.
|
|
345
|
+
*
|
|
346
|
+
* O bloco é o mesmo texto - as regras que um agente lê ANTES de escrever UI - e cada ferramenta
|
|
347
|
+
* escolheu um arquivo diferente para lê-lo. Escrever nos três custa dois `writeFile` a mais e
|
|
348
|
+
* multiplica os editores cobertos sem escrever motor novo, que era a lacuna mais barata da análise de
|
|
349
|
+
* DX (05/08): a governança só chegava a quem usa Claude Code.
|
|
350
|
+
*
|
|
351
|
+
* `AGENTS.md` é o nome que Codex e vários outros leem; `.cursor/rules/*.mdc` é o do Cursor, e ele
|
|
352
|
+
* exige frontmatter com `alwaysApply` senão a regra fica dormindo até alguém citá-la.
|
|
353
|
+
*/
|
|
354
|
+
const HOMES = [
|
|
355
|
+
{ path: "CLAUDE.md" },
|
|
356
|
+
{ path: "AGENTS.md" },
|
|
357
|
+
{
|
|
358
|
+
path: ".cursor/rules/synthesisui.mdc",
|
|
359
|
+
frontmatter: "---\ndescription: Design system rules - read before writing UI\nalwaysApply: true\n---\n\n",
|
|
360
|
+
},
|
|
361
|
+
];
|
|
343
362
|
export async function syncClaudeMd(projectRoot) {
|
|
344
363
|
const installed = await readInstalled(projectRoot);
|
|
345
364
|
const region = await renderRegion(projectRoot, installed);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
existing =
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
365
|
+
let createdAny = false;
|
|
366
|
+
for (const home of HOMES) {
|
|
367
|
+
const path = join(projectRoot, home.path);
|
|
368
|
+
let existing = null;
|
|
369
|
+
try {
|
|
370
|
+
existing = await readFile(path, "utf8");
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
existing = null;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* O ARQUIVO NOVO SÓ NASCE PARA O CLAUDE.md.
|
|
377
|
+
*
|
|
378
|
+
* Criar `AGENTS.md` num repo que nunca teve um é decidir por outra pessoa qual agente ela usa, e
|
|
379
|
+
* um arquivo que apareceu sozinho na raiz é a primeira coisa que alguém apaga com raiva. Nos
|
|
380
|
+
* outros dois a regra é: se o arquivo existe, o bloco entra e se mantém atualizado; se não
|
|
381
|
+
* existe, não inventamos um.
|
|
382
|
+
*/
|
|
383
|
+
if (existing === null) {
|
|
384
|
+
if (home.path !== "CLAUDE.md")
|
|
385
|
+
continue;
|
|
386
|
+
await mkdir(dirname(path), { recursive: true }).catch(() => { });
|
|
387
|
+
await writeFile(path, `${home.frontmatter ?? ""}${region}\n`, "utf8");
|
|
388
|
+
createdAny = true;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const startIdx = existing.indexOf(START);
|
|
392
|
+
const endIdx = existing.indexOf(END);
|
|
393
|
+
let next;
|
|
394
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
395
|
+
next =
|
|
396
|
+
existing.slice(0, startIdx) +
|
|
397
|
+
region +
|
|
398
|
+
existing.slice(endIdx + END.length);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
const sep = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
402
|
+
next = `${existing}${sep}${region}\n`;
|
|
403
|
+
}
|
|
404
|
+
if (next !== existing)
|
|
405
|
+
await writeFile(path, next, "utf8");
|
|
366
406
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
407
|
+
return { created: createdAny, count: installed.length };
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Onde o bloco JÁ está, para o `connect` poder dizer isso em vez de ficar calado.
|
|
411
|
+
*
|
|
412
|
+
* Um repo com `AGENTS.md` e `.cursor/` ganhou governança nos três lugares e não tinha como saber -
|
|
413
|
+
* e o que não se diz não é adotado.
|
|
414
|
+
*/
|
|
415
|
+
export async function blockHomes(projectRoot) {
|
|
416
|
+
const found = [];
|
|
417
|
+
for (const home of HOMES) {
|
|
418
|
+
const raw = await readFile(join(projectRoot, home.path), "utf8").catch(() => null);
|
|
419
|
+
if (raw?.includes(START))
|
|
420
|
+
found.push(home.path);
|
|
370
421
|
}
|
|
371
|
-
|
|
372
|
-
return { created: false, count: installed.length };
|
|
422
|
+
return found;
|
|
373
423
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { body, paint, section, snippet } from "../output.js";
|
|
4
|
+
/**
|
|
5
|
+
* `synthesisui ci` - a governança dentro do PR, em quatro linhas e sem Action nenhuma.
|
|
6
|
+
*
|
|
7
|
+
* A objeção que mata qualquer plataforma de governança é a terceira aba: se a checagem não está no
|
|
8
|
+
* PR, o engenheiro ignora. O doctor sempre pôde rodar em CI - offline, sem login, 1,8s sobre 2797
|
|
9
|
+
* arquivos - e ninguém ia descobrir isso lendo `--help`.
|
|
10
|
+
*
|
|
11
|
+
* O QUE ESTE COMANDO NÃO FAZ, e é a parte importante: ele não instala uma Action nossa. O GitHub lê
|
|
12
|
+
* `::warning file=…,line=…` do stdout de qualquer step, então a integração inteira é uma linha - e uma
|
|
13
|
+
* Action publicada seria uma peça a mais para manter oferecendo exactamente o mesmo. Menos superfície
|
|
14
|
+
* nossa no repo de outra pessoa é uma decisão de produto, não economia.
|
|
15
|
+
*
|
|
16
|
+
* E o workflow que ele imprime é uma CATRACA, nunca um portão: sem baseline nada reprova, com
|
|
17
|
+
* baseline reprova só a regressão. Um check que fica vermelho desde o primeiro dia é um check que
|
|
18
|
+
* alguém contorna na segunda semana - e aí o time cria a biblioteca paralela que o sistema existia
|
|
19
|
+
* para evitar.
|
|
20
|
+
*/
|
|
21
|
+
const WORKFLOW = `name: design system
|
|
22
|
+
|
|
23
|
+
on: [pull_request]
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
drift:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
# Annotations on the exact line of the diff. Nothing fails here.
|
|
31
|
+
- run: npx synthesisui@latest doctor --format=github
|
|
32
|
+
# The ratchet: fails ONLY when drift went up. With no baseline committed,
|
|
33
|
+
# this step says what to do and passes.
|
|
34
|
+
- run: npx synthesisui@latest doctor --baseline
|
|
35
|
+
`;
|
|
36
|
+
const PATH = ".github/workflows/design-system.yml";
|
|
37
|
+
export async function ci(opts) {
|
|
38
|
+
const root = resolve(opts.dir ?? process.cwd());
|
|
39
|
+
if (!opts.write) {
|
|
40
|
+
console.log(section("The design system in your pipeline"));
|
|
41
|
+
console.log(body("Two steps, no Action to install, no login, and nothing that fails on the debt you already have:"));
|
|
42
|
+
console.log("");
|
|
43
|
+
console.log(paint.dim(` # ${PATH}`));
|
|
44
|
+
for (const line of WORKFLOW.split("\n"))
|
|
45
|
+
console.log(` ${line}`);
|
|
46
|
+
console.log(section("The contract"));
|
|
47
|
+
console.log(body("--format=github annotations on the exact line, always warnings"));
|
|
48
|
+
console.log(body("--format=sarif for code-scanning or SonarQube, same findings"));
|
|
49
|
+
console.log(body("--baseline exit 1 only when drift went UP, in total or in any file"));
|
|
50
|
+
console.log(body("--write-baseline freeze where you are today, and commit it - yesterday's"));
|
|
51
|
+
console.log(body(" debt stops hurting without going uncounted"));
|
|
52
|
+
console.log(body("--strict exit 1 on ANY finding. Honest, and red on day one in a"));
|
|
53
|
+
console.log(body(" repo with history. Prefer the baseline."));
|
|
54
|
+
console.log("");
|
|
55
|
+
console.log(body("Write it here, and the first baseline with it:"));
|
|
56
|
+
console.log(paint.blue(snippet(["npx synthesisui@latest ci --write"])));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const file = join(root, PATH);
|
|
60
|
+
const before = await readFile(file, "utf8").catch(() => null);
|
|
61
|
+
if (before === WORKFLOW) {
|
|
62
|
+
console.log(section("CI"));
|
|
63
|
+
console.log(body(`${PATH} is already exactly this. Nothing to do.`));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* NUNCA SOBRESCREVE UM WORKFLOW EXISTENTE. Um arquivo de pipeline é território de outra pessoa, e
|
|
68
|
+
* "eu troquei o seu CI" é a última coisa que uma ferramenta de governança pode fazer e sobreviver.
|
|
69
|
+
*/
|
|
70
|
+
if (before !== null) {
|
|
71
|
+
console.log(section("CI"));
|
|
72
|
+
console.log(body(`${PATH} already exists and is different, so nothing was written - a pipeline file is not ours to overwrite. Add the two steps by hand:`));
|
|
73
|
+
console.log("");
|
|
74
|
+
for (const line of WORKFLOW.split("\n").slice(6))
|
|
75
|
+
console.log(` ${line}`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
await mkdir(dirname(file), { recursive: true });
|
|
79
|
+
await writeFile(file, WORKFLOW, "utf8");
|
|
80
|
+
console.log(section("CI written"));
|
|
81
|
+
console.log(body(PATH));
|
|
82
|
+
console.log(body(paint.dim("Now freeze today's line so the first PR is not red for debt nobody in it wrote:")));
|
|
83
|
+
console.log(paint.blue(snippet(["npx synthesisui@latest doctor --write-baseline"])));
|
|
84
|
+
}
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { wireAgent } from "../agent-wiring.js";
|
|
4
|
-
import { syncClaudeMd } from "../claude-md.js";
|
|
5
|
-
import { body, section, snippet } from "../output.js";
|
|
4
|
+
import { blockHomes, syncClaudeMd } from "../claude-md.js";
|
|
5
|
+
import { body, paint, section, snippet } from "../output.js";
|
|
6
6
|
import { IMPORT_SKILL, IMPORT_SKILL_PATH } from "../skill-import.js";
|
|
7
|
+
import { INIT_SKILL, INIT_SKILL_PATH } from "../skill-init.js";
|
|
8
|
+
import { ci } from "./ci.js";
|
|
7
9
|
/**
|
|
8
10
|
* `synthesisui connect` - put the three layers where they actually run.
|
|
9
11
|
*
|
|
@@ -20,6 +22,11 @@ import { IMPORT_SKILL, IMPORT_SKILL_PATH } from "../skill-import.js";
|
|
|
20
22
|
* not a default, and it deserves its own answer rather than riding along inside
|
|
21
23
|
* a setup command.
|
|
22
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* Skills que a gente já distribuiu com outro nome. Uma linha por renomeação, para sempre - o repo de
|
|
27
|
+
* um cliente que rodou `connect` há um mês não sabe que o nome mudou.
|
|
28
|
+
*/
|
|
29
|
+
const LEGACY_SKILLS = ["import-design-system"];
|
|
23
30
|
export async function connect(opts) {
|
|
24
31
|
const root = opts.dir ?? process.cwd();
|
|
25
32
|
// Both unless one is explicitly turned off - somebody who says `--no-hook`
|
|
@@ -36,12 +43,26 @@ export async function connect(opts) {
|
|
|
36
43
|
: "· .claude/settings.json already had it"));
|
|
37
44
|
console.log(snippet([wired.command]));
|
|
38
45
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
/**
|
|
47
|
+
* OS CAMINHOS DE MCP, POR NOME - e o número certo. Dizia "four tools" desde quando eram quatro; são
|
|
48
|
+
* 11, e um número velho na primeira tela é a coisa mais fácil de perder a confiança de alguém.
|
|
49
|
+
*/
|
|
50
|
+
if (want.mcp && wired.mcp !== "skipped") {
|
|
51
|
+
for (const one of wired.mcp)
|
|
52
|
+
console.log(body(one.status === "added"
|
|
53
|
+
? `✓ ${one.path.padEnd(22)} 11 tools, so the agent can ask instead of guess`
|
|
54
|
+
: `· ${one.path.padEnd(22)} already had it`));
|
|
43
55
|
}
|
|
44
|
-
|
|
56
|
+
/**
|
|
57
|
+
* ONDE O BLOCO CAIU, DITO POR NOME.
|
|
58
|
+
*
|
|
59
|
+
* O mesmo bloco de regras é escrito em `CLAUDE.md`, e em `AGENTS.md` e `.cursor/rules/*.mdc`
|
|
60
|
+
* QUANDO ELES JÁ EXISTEM - criar um arquivo de agente que ninguém pediu é decidir por outra pessoa
|
|
61
|
+
* qual ferramenta ela usa. Dizer os nomes é o que faz um time descobrir que a governança já chegou
|
|
62
|
+
* no editor dele: até 05/08 ela só alcançava quem usa Claude Code, e ninguém tinha como saber.
|
|
63
|
+
*/
|
|
64
|
+
for (const home of await blockHomes(root))
|
|
65
|
+
console.log(body(`✓ ${home.padEnd(22)} ${home === "CLAUDE.md" ? "rewritten for what is installed" : "the same rules, where this agent reads them"}`));
|
|
45
66
|
/**
|
|
46
67
|
* The fourth layer, and the one that had no installer at all: the import
|
|
47
68
|
* skill existed only in our own repo, so the only way to get it was to clone
|
|
@@ -52,17 +73,70 @@ export async function connect(opts) {
|
|
|
52
73
|
* already idempotent, and rerunning it is already the answer to "did
|
|
53
74
|
* something change" - so nobody has to remember a second command.
|
|
54
75
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
/**
|
|
77
|
+
* AS DUAS SKILLS, e o `/sui-init` é a que importa mais aqui: é a PRIMEIRA CORRIDA, e uma skill que
|
|
78
|
+
* só aparece depois de reiniciar o editor não serve para a primeira corrida de ninguém. Ela vem no
|
|
79
|
+
* mesmo `connect` que ainda vai pedir o reinício - então quando a pessoa reabre, as duas existem.
|
|
80
|
+
*/
|
|
81
|
+
const skills = [
|
|
82
|
+
{
|
|
83
|
+
path: INIT_SKILL_PATH,
|
|
84
|
+
source: INIT_SKILL,
|
|
85
|
+
label: "/sui-init",
|
|
86
|
+
what: "the first run, start to finish",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
path: IMPORT_SKILL_PATH,
|
|
90
|
+
source: IMPORT_SKILL,
|
|
91
|
+
label: "/sui-import-ds",
|
|
92
|
+
what: "the import, orchestrated",
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* A PASTA VELHA SAI, e isto é obrigatório numa renomeação de skill distribuída.
|
|
97
|
+
*
|
|
98
|
+
* O nome decide o CAMINHO, então renomear `import-design-system` para `sui-import-ds` sem apagar o
|
|
99
|
+
* antigo deixa, no repo de um cliente, uma cópia de um pipeline que não existe mais - e o agente
|
|
100
|
+
* dele tem duas skills descrevendo esteiras diferentes, sem jeito de saber qual é a viva. É a mesma
|
|
101
|
+
* falha que o spec do gêmeo existe para impedir, entrando pela porta de trás.
|
|
102
|
+
*
|
|
103
|
+
* `rm -rf` de um caminho NOSSO e nada além: a pasta é escrita por este comando, o conteúdo é o
|
|
104
|
+
* nosso texto, e ninguém edita uma skill gerada esperando que ela sobreviva a um `connect`.
|
|
105
|
+
*/
|
|
106
|
+
for (const legacy of LEGACY_SKILLS) {
|
|
107
|
+
const dir = join(root, ".claude", "skills", legacy);
|
|
108
|
+
const had = await readFile(join(dir, "SKILL.md"), "utf8").catch(() => null);
|
|
109
|
+
if (had == null)
|
|
110
|
+
continue;
|
|
111
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
112
|
+
console.log(body(`✕ /${legacy.padEnd(21)} removed - renamed to /sui-import-ds`));
|
|
113
|
+
}
|
|
114
|
+
for (const skill of skills) {
|
|
115
|
+
const skillPath = join(root, skill.path);
|
|
116
|
+
const before = await readFile(skillPath, "utf8").catch(() => null);
|
|
117
|
+
if (before !== skill.source) {
|
|
118
|
+
await mkdir(dirname(skillPath), { recursive: true });
|
|
119
|
+
await writeFile(skillPath, skill.source, "utf8");
|
|
120
|
+
}
|
|
121
|
+
console.log(body(before === skill.source
|
|
122
|
+
? `· ${skill.label.padEnd(22)} already current`
|
|
123
|
+
: before == null
|
|
124
|
+
? `✓ ${skill.label.padEnd(22)} ${skill.what}`
|
|
125
|
+
: `✓ ${skill.label.padEnd(22)} updated to this CLI's pipeline`));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* O CI: escrito só quando pedido, e OFERECIDO sempre - com o que ele faz em duas linhas, porque
|
|
129
|
+
* "adicione CI" sem dizer que nada reprova dívida herdada não é uma oferta, é um risco.
|
|
130
|
+
*/
|
|
131
|
+
if (opts.ci) {
|
|
132
|
+
console.log("");
|
|
133
|
+
await ci({ dir: root, write: true });
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
console.log("");
|
|
137
|
+
console.log(body(paint.dim("Your PRs can carry this too: annotations on the exact line, and a ratchet that fails only when drift goes UP - never on the debt you already have.")));
|
|
138
|
+
console.log(paint.blue(snippet(["npx synthesisui@latest ci"])));
|
|
60
139
|
}
|
|
61
|
-
console.log(body(before === IMPORT_SKILL
|
|
62
|
-
? "· /import-design-system already current"
|
|
63
|
-
: before == null
|
|
64
|
-
? "✓ /import-design-system the import, orchestrated"
|
|
65
|
-
: "✓ /import-design-system updated to this CLI's pipeline"));
|
|
66
140
|
// The step that cost a round trip the first time this was tried by hand,
|
|
67
141
|
// and would cost every single person one.
|
|
68
142
|
console.log("");
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
-
import { join, relative, resolve } from "node:path";
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { describeFix, planFix, readerFor, writeFix, } from "../doctor/apply-fix.js";
|
|
4
|
+
import { baselineOf, compareToBaseline, describeVerdict, githubAnnotations, sarifOf, } from "../doctor/ci-format.js";
|
|
3
5
|
import { findDivergences } from "../doctor/coherence.js";
|
|
4
6
|
import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
|
|
5
7
|
import { checkContracts } from "../doctor/contract-check.js";
|
|
@@ -52,6 +54,25 @@ const SKIP = new Set([
|
|
|
52
54
|
".vercel",
|
|
53
55
|
// Our own installed artifacts are the answer, not the problem.
|
|
54
56
|
"_synthesisui",
|
|
57
|
+
/**
|
|
58
|
+
* SAÍDA DE GERADOR, e cada nome aqui foi medido num repo real antes de entrar.
|
|
59
|
+
*
|
|
60
|
+
* `_build` é o do Sphinx, e no repo do dono ele carrega 10 arquivos de UI - o CSS do tema
|
|
61
|
+
* `alabaster`, com a paleta do TEMA e não a dele. Medido em 05/08: as 200 anotações do formato de
|
|
62
|
+
* CI saíram TODAS de lá, então o teto foi consumido inteiro por um diretório que ninguém edita e
|
|
63
|
+
* nenhum achado de verdade chegou ao PR. Anotar arquivo gerado é o jeito mais rápido de alguém
|
|
64
|
+
* silenciar a ferramenta.
|
|
65
|
+
*/
|
|
66
|
+
"_build",
|
|
67
|
+
"__pycache__",
|
|
68
|
+
".venv",
|
|
69
|
+
"venv",
|
|
70
|
+
"vendor",
|
|
71
|
+
".output",
|
|
72
|
+
".svelte-kit",
|
|
73
|
+
".cache",
|
|
74
|
+
".parcel-cache",
|
|
75
|
+
".nuxt",
|
|
55
76
|
]);
|
|
56
77
|
export async function* walk(dir,
|
|
57
78
|
/** Filled with nested projects that were SKIPPED - each speaks its own
|
|
@@ -387,6 +408,8 @@ async function readWiring(root, slug) {
|
|
|
387
408
|
}
|
|
388
409
|
return w;
|
|
389
410
|
}
|
|
411
|
+
/** Onde o retrato mora por default: dentro do que a esteira já escreve, e commitável. */
|
|
412
|
+
const DEFAULT_BASELINE = "_synthesisui/drift-baseline.json";
|
|
390
413
|
export async function doctor(opts) {
|
|
391
414
|
const root = resolve(opts.dir ?? process.cwd());
|
|
392
415
|
const scopes = (opts.scopes ?? []).map((s) => resolve(root, s));
|
|
@@ -470,6 +493,26 @@ export async function doctor(opts) {
|
|
|
470
493
|
}
|
|
471
494
|
const breaches = checkContracts(tallyToInventory(tally, 400), documents);
|
|
472
495
|
const d = diagnose(reports);
|
|
496
|
+
/**
|
|
497
|
+
* A SAÍDA DE MÁQUINA SAI AQUI, E SOZINHA.
|
|
498
|
+
*
|
|
499
|
+
* Antes de qualquer linha humana: um log de CI com as duas coisas é um log que ninguém lê, e um
|
|
500
|
+
* SARIF com um cabeçalho bonito no meio não parseia. As anotações do GitHub não precisam de Action
|
|
501
|
+
* nenhuma - ele lê `::warning` do stdout de qualquer step, então a integração é uma linha no
|
|
502
|
+
* workflow deles.
|
|
503
|
+
*
|
|
504
|
+
* E NÃO REPROVA: quem decide o exit code é a catraca, mais abaixo. Anotação em cima de dívida
|
|
505
|
+
* herdada é informação; reprovar por ela é o pedágio que faz um time criar biblioteca paralela.
|
|
506
|
+
*/
|
|
507
|
+
if (opts.format === "github") {
|
|
508
|
+
for (const line of githubAnnotations(d))
|
|
509
|
+
console.log(line);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (opts.format === "sarif") {
|
|
513
|
+
console.log(sarifOf(d, opts.cli));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
473
516
|
// Nine releases in one evening added a section each, every one justified on
|
|
474
517
|
// its own, and nobody read the whole. The result was 151 lines carrying about
|
|
475
518
|
// eight lines of meaning. Detail is now something you ask for.
|
|
@@ -1212,6 +1255,67 @@ export async function doctor(opts) {
|
|
|
1212
1255
|
}
|
|
1213
1256
|
console.log("");
|
|
1214
1257
|
}
|
|
1258
|
+
/**
|
|
1259
|
+
* A CATRACA, e ela vem ANTES do `--strict` de propósito.
|
|
1260
|
+
*
|
|
1261
|
+
* `--strict` reprova por qualquer achado, o que num repo legado é vermelho no dia um e um portão
|
|
1262
|
+
* vermelho para sempre é um portão que alguém contorna. Com um baseline, o que reprova é a
|
|
1263
|
+
* REGRESSÃO - e aí a dívida de ontem para de doer sem deixar de ser contada.
|
|
1264
|
+
*/
|
|
1265
|
+
/**
|
|
1266
|
+
* O CONSERTO, ANTES DE QUALQUER RELATÓRIO - ver `apply-fix.ts`.
|
|
1267
|
+
*
|
|
1268
|
+
* Sai aqui porque quem pediu `--fix` quer o resultado da edição, não o diagnóstico de novo: imprimir
|
|
1269
|
+
* 1500 achados e depois dizer "troquei 900" é fazer a pessoa procurar a linha que importa.
|
|
1270
|
+
*/
|
|
1271
|
+
if (opts.fix) {
|
|
1272
|
+
const read = await readerFor(root, d.findings.filter((f) => f.token).map((f) => f.file));
|
|
1273
|
+
const { result, next } = planFix(d, read);
|
|
1274
|
+
if (!opts.dry)
|
|
1275
|
+
await writeFix(root, next);
|
|
1276
|
+
console.log(section(opts.dry ? "What --fix would do" : "Fixed what had an answer"));
|
|
1277
|
+
for (const line of describeFix(result, opts.dry === true))
|
|
1278
|
+
console.log(body(line));
|
|
1279
|
+
if (!opts.dry && result.applied.length > 0) {
|
|
1280
|
+
console.log("");
|
|
1281
|
+
console.log(body(paint.dim("Read the diff before committing - it is your code, and this replaced literals with the tokens your own system declares.")));
|
|
1282
|
+
}
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (opts.writeBaseline) {
|
|
1286
|
+
const path = resolve(root, opts.baseline ?? DEFAULT_BASELINE);
|
|
1287
|
+
await mkdir(dirname(path), { recursive: true }).catch(() => { });
|
|
1288
|
+
await writeFile(path, `${JSON.stringify(baselineOf(d, {
|
|
1289
|
+
at: new Date().toISOString(),
|
|
1290
|
+
...(opts.cli ? { cli: opts.cli } : {}),
|
|
1291
|
+
...(opts.scopes?.length ? { scope: opts.scopes.join(",") } : {}),
|
|
1292
|
+
}), null, 1)}\n`, "utf8");
|
|
1293
|
+
console.log(section("Baseline written"));
|
|
1294
|
+
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.`));
|
|
1295
|
+
console.log(body(paint.dim(relative(root, path))));
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (opts.baseline || opts.compareBaseline) {
|
|
1299
|
+
const path = resolve(root, opts.baseline ?? DEFAULT_BASELINE);
|
|
1300
|
+
const raw = await readFile(path, "utf8").catch(() => null);
|
|
1301
|
+
const base = raw
|
|
1302
|
+
? JSON.parse(raw)
|
|
1303
|
+
: null;
|
|
1304
|
+
if (!base?.baseline) {
|
|
1305
|
+
console.log(section("Baseline"));
|
|
1306
|
+
console.log(body(`No readable baseline at ${relative(root, path)}. Write one first: npx synthesisui doctor --write-baseline`));
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
const verdict = compareToBaseline(d, base, {
|
|
1310
|
+
...(opts.scopes?.length ? { scope: opts.scopes.join(",") } : {}),
|
|
1311
|
+
});
|
|
1312
|
+
console.log(section(verdict.worse ? "Drift went up" : "Ratchet"));
|
|
1313
|
+
for (const line of describeVerdict(verdict))
|
|
1314
|
+
console.log(body(line));
|
|
1315
|
+
if (verdict.worse)
|
|
1316
|
+
process.exitCode = 1;
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1215
1319
|
if (opts.strict) {
|
|
1216
1320
|
const mine = d.findings.length > 0 || overrides.length > 0;
|
|
1217
1321
|
// The two system lenses are deliberately NOT in here by default. A team
|