synthesisui 0.16.419 → 0.16.421

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.
@@ -1,7 +1,31 @@
1
1
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { hasCodexBlock, pinnedInCodex, withCodexBlock } from "./codex-mcp.js";
4
- const HOOK_MATCHER = "Write|Edit|MultiEdit";
4
+ /**
5
+ * QUANDO A CHECAGEM RODA - e por que `Bash` entrou em 12/09.
6
+ *
7
+ * ═══ O QUE O CLIENTE VIA ═══
8
+ *
9
+ * O filtro era `Write|Edit|MultiEdit`, e com isso a garantia inteira dependia de qual ferramenta o
10
+ * agente escolhesse. Medido no `codelevel` em três sessões: toda edição em lote passou por comando
11
+ * de shell e a checagem produziu **0 linhas**; na única edição feita pela ferramenta de edição ela
12
+ * falou na hora, com a regra do sistema nomeada. O produto promete atuar quando ele escreve
13
+ * frontend - não quando o agente segura a caneta certa.
14
+ *
15
+ * Um comando de shell não diz quais arquivos tocou, então quem responde é a árvore de trabalho -
16
+ * ver `changed-files.ts`, onde também está o custo medido.
17
+ */
18
+ const HOOK_MATCHER = "Write|Edit|MultiEdit|Bash";
19
+ /**
20
+ * O FILTRO QUE ESTE PRODUTO ESCREVIA ANTES DE 12/09 - e a única string que ele se autoriza a
21
+ * alargar.
22
+ *
23
+ * Quem conectou antes disso tem no arquivo dele um filtro que NÓS escrevemos, e deixá-lo como está
24
+ * seria entregar a correção só para quem instala do zero. Alargar um filtro que uma PESSOA
25
+ * escreveu é outra coisa, e continua proibido: a comparação é por igualdade exata com a nossa
26
+ * string antiga, nunca por parecer com ela.
27
+ */
28
+ const HOOK_MATCHER_BEFORE_SHELL = "Write|Edit|MultiEdit";
5
29
  /**
6
30
  * Exportado porque a REGRA é uma só: a pasta de uma ferramenta é a evidência de que ela é usada, e
7
31
  * a mesma evidência decide o MCP daqui e a casa do bloco em `claude-md.ts`. Duas cópias deste
@@ -116,11 +140,27 @@ async function wireHook(root, command) {
116
140
  * protegia continua protegido: o matcher fica como estiver, os hooks vizinhos ficam, e uma segunda
117
141
  * entrada nunca é criada - duas entradas rodariam o verificador duas vezes por escrita.
118
142
  */
119
- const mine = post
120
- .flatMap((e) => e.hooks ?? [])
121
- .find((h) => (h.command ?? "").includes("synthesisui"));
122
- if (mine) {
143
+ const owner = post.find((e) => (e.hooks ?? []).some((h) => (h.command ?? "").includes("synthesisui")));
144
+ const mine = (owner?.hooks ?? []).find((h) => (h.command ?? "").includes("synthesisui"));
145
+ if (owner && mine) {
123
146
  const was = mine.command ?? "";
147
+ /**
148
+ * O FILTRO NOSSO ANTIGO SOBE JUNTO - ver `HOOK_MATCHER_BEFORE_SHELL`.
149
+ *
150
+ * Sem isto, a checagem passar a valer para edição por shell alcançaria só quem instalasse do
151
+ * zero: quem já tinha conectado ficaria com o filtro estreito para sempre, e nada no produto
152
+ * diria isso a ele.
153
+ *
154
+ * **E SÓ QUANDO A ENTRADA É NOSSA INTEIRA** - achado da revisão de contrato no fecho, e a
155
+ * fronteira estava larga demais. `matcher` vale para TODOS os comandos da entrada: se ele
156
+ * acrescentou um hook dele ali ao lado - um formatador, um linter -, alargar o filtro passaria
157
+ * a disparar o comando DELE depois de todo comando de shell, sem ele ter pedido. É a mesma lei
158
+ * que impede escrever arquivo de um agente que ele não escolheu.
159
+ */
160
+ const onlyOurs = (owner.hooks ?? []).every((h) => (h.command ?? "").includes("synthesisui"));
161
+ const widen = owner.matcher === HOOK_MATCHER_BEFORE_SHELL && onlyOurs;
162
+ if (widen)
163
+ owner.matcher = HOOK_MATCHER;
124
164
  /**
125
165
  * SÓ UM PIN NOSSO É SUBSTITUÍDO, e a fronteira é estreita de propósito.
126
166
  *
@@ -132,12 +172,16 @@ async function wireHook(root, command) {
132
172
  */
133
173
  const pinnedAt = /synthesisui@(\d+\.\d+\.\d+)/.exec(was)?.[1];
134
174
  const proposedAt = /synthesisui@(\d+\.\d+\.\d+)/.exec(command)?.[1];
135
- if (!pinnedAt || !proposedAt || pinnedAt === proposedAt)
175
+ const samePin = !pinnedAt || !proposedAt || pinnedAt === proposedAt;
176
+ if (samePin && !widen)
136
177
  return { status: "already there", command: was || command };
137
- mine.command = command;
178
+ if (!samePin)
179
+ mine.command = command;
138
180
  await mkdir(dir, { recursive: true });
139
181
  await writeFile(path, `${JSON.stringify({ ...settings, hooks: { ...hooks, PostToolUse: post } }, null, 2)}\n`, "utf8");
140
- return { status: "updated", command, was };
182
+ return samePin
183
+ ? { status: "updated", command: was || command }
184
+ : { status: "updated", command, was };
141
185
  }
142
186
  post.push({
143
187
  matcher: HOOK_MATCHER,
@@ -0,0 +1,56 @@
1
+ import { execFile } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ const run = promisify(execFile);
6
+ /**
7
+ * O QUE A ÁRVORE DE TRABALHO TEM DE DIFERENTE DO ÚLTIMO COMMIT, mais o que não está rastreado.
8
+ *
9
+ * `--relative` e o `cwd` na raiz do projeto: num monorepo cujo `_synthesisui` mora em
10
+ * `apps/landing/`, os caminhos do git saem a partir do topo da ÁRVORE, e compará-los com os do
11
+ * projeto é comparar dois endereços diferentes para o mesmo arquivo. É o mesmo defeito que o
12
+ * `HEAD:./` já conserta do outro lado.
13
+ */
14
+ async function listed(root) {
15
+ const out = [];
16
+ for (const args of [
17
+ ["diff", "--name-only", "--relative", "HEAD", "--", "."],
18
+ ["ls-files", "--others", "--exclude-standard"],
19
+ ]) {
20
+ const { stdout } = await run("git", args, {
21
+ cwd: root,
22
+ maxBuffer: 4 * 1024 * 1024,
23
+ }).catch(() => ({ stdout: "" }));
24
+ for (const line of stdout.split("\n"))
25
+ if (line.trim())
26
+ out.push(line.trim());
27
+ }
28
+ return [...new Set(out)];
29
+ }
30
+ /**
31
+ * OS ARQUIVOS INTERESSANTES QUE MUDARAM DESDE `since`, do mais recente para o mais antigo.
32
+ *
33
+ * `since` é o instante da última checagem. Sem ele, a árvore inteira de trabalho responderia - e
34
+ * num repositório com trinta arquivos em aberto o primeiro comando de shell do dia produziria um
35
+ * relatório sobre trabalho de ontem. O corte por instante é o que faz a resposta ser *"o que este
36
+ * comando acabou de fazer"*.
37
+ */
38
+ export async function changedSince(root, since, keep) {
39
+ const out = [];
40
+ for (const rel of await listed(root)) {
41
+ if (!keep(rel))
42
+ continue;
43
+ const info = await stat(join(root, rel)).catch(() => null);
44
+ if (!info?.isFile())
45
+ continue;
46
+ /**
47
+ * `<` E NÃO `<=`, e o lado do erro é declarado: um arquivo escrito no MESMO milissegundo em
48
+ * que o relógio andou é RELATADO, não perdido. O preço é um relatório repetido numa corrida
49
+ * rara; o preço do outro lado é uma escrita que ninguém checa nunca mais.
50
+ */
51
+ if (info.mtimeMs < since)
52
+ continue;
53
+ out.push({ rel, at: info.mtimeMs });
54
+ }
55
+ return out.sort((a, b) => b.at - a.at);
56
+ }
@@ -4,6 +4,7 @@ import { codexPinBefore, exists, wireAgent } from "../agent-wiring.js";
4
4
  import { AGENTS, chooseAgents, filesOf, rememberChoice, } from "../agents-chosen.js";
5
5
  import { blockHomes, syncClaudeMd } from "../claude-md.js";
6
6
  import { resolveRegistry } from "../config.js";
7
+ import { guaranteeIfNew } from "../guarantee.js";
7
8
  import { notAProject, projectRootFrom } from "../is-a-project.js";
8
9
  import { fmt, say } from "../lang.js";
9
10
  import { body, bodyWrapped, paint, section, snippet } from "../output.js";
@@ -14,6 +15,7 @@ import { SKILLS } from "../skills.js";
14
15
  import { add, ensureGovernanceHome } from "./add.js";
15
16
  import { reportWhatIsLeft } from "./align.js";
16
17
  import { ci } from "./ci.js";
18
+ import { loadSystem } from "./doctor.js";
17
19
  import { MCP_TOOL_COUNT } from "./mcp.js";
18
20
  import { installedSlugs } from "./sync.js";
19
21
  /**
@@ -672,6 +674,37 @@ export async function connect(opts) {
672
674
  console.log("");
673
675
  await ci({ dir: root, write: true });
674
676
  }
677
+ /**
678
+ * ATÉ ONDE A GARANTIA VAI - uma vez, e de novo só quando a resposta muda.
679
+ *
680
+ * ═══ POR QUE AQUI ═══
681
+ *
682
+ * Este é o comando que LIGA a checagem, então é o único lugar onde a fronteira dela é notícia. O
683
+ * que o produto passa a fazer sozinho no repositório de alguém precisa vir com o que ele NÃO
684
+ * faz, na mesma tela - senão o silêncio da checagem lê como aprovação, que é a lacuna calada da
685
+ * lei 8.
686
+ *
687
+ * A frase sai depois da fiação de propósito: ela fala do estado que este comando acabou de
688
+ * deixar no disco, e não do que havia antes dele.
689
+ */
690
+ {
691
+ const { table } = await loadSystem(root).catch(() => ({
692
+ table: { name: null, slug: null, utilities: new Map() },
693
+ }));
694
+ const said = await guaranteeIfNew(root, {
695
+ name: table.name ?? table.slug,
696
+ utilities: table.utilities,
697
+ }).catch(() => null);
698
+ if (said) {
699
+ console.log("");
700
+ console.log(section(say("How far this check goes here")));
701
+ for (const line of said) {
702
+ console.log("");
703
+ for (const wrapped of bodyWrapped(fmt(line.text, line.values)))
704
+ console.log(wrapped);
705
+ }
706
+ }
707
+ }
675
708
  /** A mesma versão que a fiação do editor recebe - os dois pinam no mesmo número. */
676
709
  await offerShellHook(opts.shell === true, opts.version);
677
710
  /**
@@ -269,6 +269,23 @@ measured) {
269
269
  }
270
270
  }
271
271
  css += `\n${real || root}`;
272
+ /**
273
+ * O ADAPTADOR DE TEMA ENTRA JUNTO - é ele que diz QUAIS utilities este sistema gera.
274
+ *
275
+ * `theme.css` carrega o bloco `@theme`, e é essa declaração que autoriza a régua a ler
276
+ * `bg-primary` como referência a `--color-primary` (ver `idiom-names.ts`). Sem ele, um projeto
277
+ * de idioma utility volta a ser medido com a régua do idioma vizinho - era o caso do
278
+ * `codelevel`, cujo `tokens.css` traz os mesmos nomes, mas dentro do escopo do sistema.
279
+ *
280
+ * As declarações são as MESMAS que já vieram acima, e a primeira vence: nenhum valor muda, só
281
+ * o marcador passa a ser visível. Ausente é o caso normal de um sistema adotado.
282
+ */
283
+ const themePointer = await readFile(join(dir, "theme.css"), "utf8").catch(() => "");
284
+ const theme = mine?.version
285
+ ? await readFile(join(dir, `v${mine.version}`, "theme.css"), "utf8").catch(() => "")
286
+ : "";
287
+ if (theme || themePointer)
288
+ css += `\n${theme || themePointer}`;
272
289
  // An ADOPTED system has no tokens.css of ours - `adopt` deliberately
273
290
  // writes no CSS, because the project's own stylesheet already works. Its
274
291
  // vocabulary lives in system.json, and without this the doctor falls
@@ -894,9 +911,11 @@ export async function doctor(opts) {
894
911
  : "") +
895
912
  " (--verbose for why)"));
896
913
  }
897
- // 0 of 0 is not a perfect score, it is an empty measurement - printing a
898
- // full bar there would be the report's first lie.
899
- const measurable = d.tokenUses + d.findings.length > 0;
914
+ /**
915
+ * 0 de 0 não é nota máxima, é medição vazia - e desde 12/09 a própria régua carrega essa
916
+ * distinção em vez de cada tela refazê-la. Ver `Reach` em `scan.ts`.
917
+ */
918
+ const measurable = d.reach.measured;
900
919
  // Before the number, because the number is the thing that misleads. An
901
920
  // installed-but-unwired system reads 0%, and 0% reads as "broken product"
902
921
  // rather than "one import missing".
@@ -1230,7 +1249,7 @@ export async function doctor(opts) {
1230
1249
  await appendEvent(root, {
1231
1250
  kind: "doctor",
1232
1251
  at: new Date().toISOString(),
1233
- coverage: d.coverage,
1252
+ ...(d.reach.measured ? { coverage: d.reach.percent } : {}),
1234
1253
  rule: COVERAGE_RULE,
1235
1254
  named: d.named,
1236
1255
  ...(d.findings.some((f) => f.crossFamily)
@@ -1321,9 +1340,10 @@ export async function doctor(opts) {
1321
1340
  console.log(body(" and every shadcn component switches over."));
1322
1341
  }
1323
1342
  }
1324
- if (hasSystem && measurable) {
1343
+ if (hasSystem && d.reach.measured) {
1344
+ const { percent, uses, of } = d.reach;
1325
1345
  console.log("");
1326
- console.log(body(`Token coverage ${meter(d.coverage)} ${paint.strong(`${String(d.coverage).padStart(3)}%`)}`));
1346
+ console.log(body(`Token coverage ${meter(percent)} ${paint.strong(`${String(percent).padStart(3)}%`)} ${paint.dim(`${uses} of ${of} design values come from the system`)}`));
1327
1347
  console.log(body(paint.dim(
1328
1348
  /**
1329
1349
  * DE QUEM É O VOCABULÁRIO, dito na própria linha.
@@ -1435,7 +1455,7 @@ export async function doctor(opts) {
1435
1455
  * O `1` não muda nada do que a frase afirma: os valores escritos à mão continuam sendo a FONTE
1436
1456
  * de onde os tokens saíram, e não um desvio deles.
1437
1457
  */
1438
- if (d.coverage === 0 && measured.system) {
1458
+ if (d.reach.measured && d.reach.percent === 0 && measured.system) {
1439
1459
  console.log(body(paint.dim(` zero is the expected start here - this system was measured FROM`)));
1440
1460
  console.log(body(paint.dim(` \`${measured.system}\`, so these values are where the tokens`)));
1441
1461
  console.log(body(paint.dim(` came from. They count once the code points at the names they became.`)));
@@ -1478,7 +1498,7 @@ export async function doctor(opts) {
1478
1498
  await appendEvent(root, {
1479
1499
  kind: "doctor",
1480
1500
  at: new Date().toISOString(),
1481
- coverage: d.coverage,
1501
+ ...(d.reach.measured ? { coverage: d.reach.percent } : {}),
1482
1502
  /** Qual régua mediu - ver `COVERAGE_RULE`. Sem isto a tendência compara duas réguas. */
1483
1503
  rule: COVERAGE_RULE,
1484
1504
  named: d.named,
@@ -1,8 +1,10 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
- import { appendEvent } from "../doctor/ledger.js";
3
+ import { changedSince } from "../changed-files.js";
4
+ import { appendEvent, ledgerPath } from "../doctor/ledger.js";
4
5
  import { diagnose, nameToWrite, scanSource } from "../doctor/scan.js";
5
- import { namesRemoved, previousText, rulesTouchedBy } from "../rule-touched.js";
6
+ import { governs, ungovernedIn } from "../governed.js";
7
+ import { namesRemoved, previousText, rulesTouchedBy, } from "../rule-touched.js";
6
8
  import { loadSystem } from "./doctor.js";
7
9
  const pass = () => ({ continue: true });
8
10
  const speak = (context) => ({
@@ -12,15 +14,59 @@ const speak = (context) => ({
12
14
  additionalContext: context,
13
15
  },
14
16
  });
15
- /** Files where a design value can even appear. A hook that parses a README on
16
- * every edit is paying for nothing. */
17
- const UI_FILE = /\.(tsx|jsx|ts|js|css|scss|vue|svelte)$/i;
18
17
  /** Written after the hook's first report of any kind, and never read for anything
19
18
  * else. Lives in our own directory, so removing the system removes the memory
20
19
  * too. It is committed with `_synthesisui/`, which means the greeting is
21
20
  * per-project and not per-teammate - a wart worth the simplicity, since the
22
21
  * second person to clone a wired repo is not the one wondering if it installed. */
23
22
  const GREETED = "_synthesisui/.hook-greeted";
23
+ /**
24
+ * QUANDO ESTA CHECAGEM OLHOU PELA ÚLTIMA VEZ - e o relógio é o registro que ela já escreve.
25
+ *
26
+ * ═══ POR QUE NÃO UM ARQUIVO NOVO ═══
27
+ *
28
+ * A primeira versão criava `_synthesisui/.hook-seen` só para carregar uma data de modificação, e a
29
+ * revisão de QA no fecho achou o custo: um arquivo NOVO no repositório dele, não rastreado, que a
30
+ * lista de ignorados gerenciada só alcança em projeto novo - porque ela é escrita uma vez e nunca
31
+ * reescrita, e isso é decisão declarada em `commands/add.ts`.
32
+ *
33
+ * O livro-razão já responde a mesma pergunta e já é ignorado: ele ganha uma linha a cada arquivo
34
+ * checado, então a data de modificação dele É o instante da última checagem. Um `doctor` também o
35
+ * move, e isso está certo - ele olhou o projeto inteiro.
36
+ *
37
+ * ═══ E SEM ELE A RESPOSTA É "NUNCA OLHEI", que não é "olhei em 1970" ═══
38
+ *
39
+ * Zero faria a árvore inteira de trabalho responder, e o primeiro comando do dia produziria um
40
+ * relatório sobre o trabalho de ontem - ver `baseline`.
41
+ */
42
+ const clockOf = async (root) => stat(ledgerPath(root)).then((s) => s.mtimeMs, () => 0);
43
+ /**
44
+ * A PRIMEIRA VEZ NÃO FALA DE TRABALHO QUE ELE NÃO ACABOU DE FAZER - achado da revisão de QA.
45
+ *
46
+ * Num repositório com cinco arquivos em aberto desde ontem, o primeiro comando de shell - um `ls`,
47
+ * um `git status` - encontraria os cinco e falaria de três. Isso é ruído sobre trabalho que
48
+ * ninguém acabou de tocar, e ruído é o que faz alguém desinstalar a checagem.
49
+ *
50
+ * Então a primeira rodada ACERTA O RELÓGIO e cala. O que se perde é um relatório, na única vez em
51
+ * que o produto acabou de ser instalado - e a tela do `connect` já disse que ele está vivo.
52
+ */
53
+ const startClock = (root) => writeFile(ledgerPath(root), "", { flag: "a" }).catch(() => { });
54
+ /**
55
+ * QUANTOS ARQUIVOS UM COMANDO DE SHELL PODE FAZER O PRODUTO RELATAR DE UMA VEZ.
56
+ *
57
+ * Um script que reescreve trinta arquivos produziria trinta relatórios numa resposta só, e o
58
+ * cabeçalho deste arquivo já diz o que acontece com uma checagem barulhenta. Três é o que cabe numa
59
+ * leitura.
60
+ *
61
+ * **E O QUE FICA DE FORA É NOMEADO** - achado da revisão de QA no fecho, e o defeito era pior que o
62
+ * corte: o relógio andava por cima de TODOS os arquivos, então do quarto em diante eles não eram
63
+ * relatados por ninguém, nunca mais. Um script de dez telas rendia três relatórios e sete arquivos
64
+ * apagados em silêncio. Agora os outros saem pelo NOME, com o comando que os olha.
65
+ *
66
+ * E a ordem é do mais recente para o mais antigo: numa lista cortada, o que o comando acabou de
67
+ * escrever é o que a pessoa tem em mente.
68
+ */
69
+ const AT_MOST = 3;
24
70
  const markGreeted = (root) => writeFile(join(root, GREETED), "", "utf8").catch(() => { });
25
71
  /** One sentence, once per project. Addressed to the agent because the hook has
26
72
  * no way to reach the person, and the person is who needs to know. */
@@ -72,13 +118,13 @@ async function greet(root, rel) {
72
118
  * O retorno continua sendo contexto: `continue: true` em todo caminho. E nenhum nome é proposto -
73
119
  * a frase diz qual regra, qual nome declarado amarra a regra ao que saiu, e devolve a decisão.
74
120
  */
75
- async function amendment(root, rel, after, rules, declared) {
121
+ async function amendment(root, rel, after, rules, names) {
76
122
  if (rules.length === 0)
77
123
  return [];
78
124
  const before = await previousText(root, rel);
79
125
  if (before == null)
80
126
  return [];
81
- const touched = rulesTouchedBy(namesRemoved(before, after, declared), rules);
127
+ const touched = rulesTouchedBy(namesRemoved(before, after, names), rules);
82
128
  if (touched.length === 0)
83
129
  return [];
84
130
  return [
@@ -121,7 +167,7 @@ async function report(root, filePath) {
121
167
  if (src == null)
122
168
  return null;
123
169
  const rel = relative(root, filePath);
124
- const broke = await amendment(root, rel, src, doctrines.flatMap((doc) => doc.rules), table.declared).catch(() => []);
170
+ const broke = await amendment(root, rel, src, doctrines.flatMap((doc) => doc.rules), table).catch(() => []);
125
171
  const d = diagnose([scanSource(rel, src, table)]);
126
172
  const named = d.findings.filter((f) => nameToWrite(f));
127
173
  /**
@@ -228,6 +274,18 @@ async function report(root, filePath) {
228
274
  }
229
275
  return lines.join("\n");
230
276
  }
277
+ /**
278
+ * O QUE O AGENTE ACABOU DE ESCREVER, pelo caminho que o cliente do agente nomeia.
279
+ *
280
+ * `Write`, `Edit` e `MultiEdit` trazem o arquivo no payload, e é a resposta mais barata que
281
+ * existe: nenhuma pergunta ao git, nenhum `stat`.
282
+ */
283
+ const namedInPayload = (input) => {
284
+ const writes = input.tool_name === "Write" ||
285
+ input.tool_name === "Edit" ||
286
+ input.tool_name === "MultiEdit";
287
+ return writes ? (input.tool_input?.file_path ?? null) : null;
288
+ };
231
289
  export async function hook(opts) {
232
290
  const root = resolve(opts.dir ?? process.cwd());
233
291
  let raw = "";
@@ -243,32 +301,66 @@ export async function hook(opts) {
243
301
  process.stdout.write(`${JSON.stringify(pass())}\n`);
244
302
  return;
245
303
  }
246
- const writes = input.tool_name === "Write" ||
247
- input.tool_name === "Edit" ||
248
- input.tool_name === "MultiEdit";
249
- const file = input.tool_input?.file_path;
250
- if (input.hook_event_name !== "PostToolUse" || !writes || !file) {
304
+ if (input.hook_event_name !== "PostToolUse") {
251
305
  process.stdout.write(`${JSON.stringify(pass())}\n`);
252
306
  return;
253
307
  }
254
- const abs = resolve(root, file);
255
- // Our own installed artifacts are the answer, not the problem - and a file
256
- // outside the project is none of our business.
308
+ const ungoverned = await ungovernedIn(root);
309
+ const named = namedInPayload(input);
257
310
  /**
258
- * O CAMINHO TESTADO É O RELATIVO - achado da revisão de QA no fecho.
311
+ * A FERRAMENTA DE EDIÇÃO DIZ O ARQUIVO; QUALQUER OUTRA COISA PERGUNTA À ÁRVORE.
259
312
  *
260
- * `abs.includes("_synthesisui")` olhava o caminho ABSOLUTO, então quem clonasse o projeto para
261
- * qualquer diretório com esse nome no meio (`~/_synthesisui-demo/app`) tinha TODOS os arquivos
262
- * pulados e um hook permanentemente mudo. O relativo estava calculado uma linha acima.
313
+ * Um comando de shell não tem como dizer o que escreveu, e era exatamente por ali que passavam
314
+ * as edições em lote das duas sessões medidas em 12/09 - ver o cabeçalho deste arquivo e
315
+ * `changed-files.ts`. As duas chamadas ao git custaram 12ms medidos, e elas vêm ANTES de
316
+ * qualquer leitura de folha de estilo: quando nada mudou, isto termina aqui.
263
317
  */
264
- const rel = relative(root, abs);
265
- const inside = !rel.startsWith("..");
266
- if (!UI_FILE.test(abs) ||
267
- !inside ||
268
- rel.split("/").includes("_synthesisui")) {
318
+ if (named) {
319
+ const rel = relative(root, resolve(root, named));
320
+ const body = governs(rel, ungoverned)
321
+ ? await report(root, join(root, rel)).catch(() => null)
322
+ : null;
323
+ process.stdout.write(`${JSON.stringify(body ? speak(body) : pass())}\n`);
324
+ return;
325
+ }
326
+ const clock = await clockOf(root);
327
+ /** Ver `startClock`: a primeira rodada acerta o relógio e cala. */
328
+ if (clock === 0) {
329
+ await startClock(root);
269
330
  process.stdout.write(`${JSON.stringify(pass())}\n`);
270
331
  return;
271
332
  }
272
- const body = await report(root, abs).catch(() => null);
273
- process.stdout.write(`${JSON.stringify(body ? speak(body) : pass())}\n`);
333
+ const changed = await changedSince(root, clock, (rel) => governs(rel, ungoverned)).catch(() => []);
334
+ if (changed.length === 0) {
335
+ process.stdout.write(`${JSON.stringify(pass())}\n`);
336
+ return;
337
+ }
338
+ const said = [];
339
+ for (const { rel } of changed.slice(0, AT_MOST)) {
340
+ const body = await report(root, join(root, rel)).catch(() => null);
341
+ if (body)
342
+ said.push(body);
343
+ }
344
+ /**
345
+ * O QUE FICOU DE FORA É NOMEADO, e com o comando que o olha - ver `AT_MOST`.
346
+ *
347
+ * O corte calado era pior do que parecia: o relógio anda por cima de TODOS os arquivos que esta
348
+ * rodada encontrou, então do quarto em diante eles não voltariam a ser vistos por ninguém. Sair
349
+ * pelo nome é o que transforma uma perda silenciosa numa lacuna declarada (lei 8).
350
+ */
351
+ const rest = changed.slice(AT_MOST);
352
+ if (rest.length > 0)
353
+ said.push([
354
+ `${rest.length} more file${rest.length === 1 ? "" : "s"} changed in this command and ${rest.length === 1 ? "was" : "were"} not checked here: ${rest
355
+ .slice(0, 10)
356
+ .map((c) => c.rel)
357
+ .join(", ")}${rest.length > 10 ? `, +${rest.length - 10} more` : ""}.`,
358
+ "Run `npx synthesisui doctor` to see them, or write one of them again and this will check it.",
359
+ ].join("\n"));
360
+ /**
361
+ * E O RELÓGIO ANDA AQUI, depois de olhar - `appendEvent` já o moveu para cada arquivo relatado,
362
+ * e esta linha cobre o caso em que todos os relatórios falharam na leitura.
363
+ */
364
+ await startClock(root);
365
+ process.stdout.write(`${JSON.stringify(said.length > 0 ? speak(said.join("\n\n")) : pass())}\n`);
274
366
  }
@@ -5,7 +5,7 @@ import { applyAnatomyPatch, hasEdits, } from "../anatomy-patch.js";
5
5
  import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
6
6
  import { craftLines } from "../baseline-craft.js";
7
7
  import { PAGES_MAX, pageCompositionOf } from "../census-pages.js";
8
- import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
8
+ import { readCredentials, readProjectConfig, readToken, resolveRegistry, sameRegistry, } from "../config.js";
9
9
  import { declaredElsewhere } from "../declared-elsewhere.js";
10
10
  import { architectureGap, architectureRule, componentHome, describeArchitecture, describeChoice, describeGap, detectArchitectures, homeLine, packagingOf, proposeNewHome, resolvesAs, } from "../doctor/architecture.js";
11
11
  import { findBrokenRefs } from "../doctor/broken-refs.js";
@@ -52,7 +52,7 @@ import { mergeNamespacePairs } from "../namespace-pairs.js";
52
52
  import { namingQueue } from "../naming-queue.js";
53
53
  import { notExpressed } from "../not-expressed.js";
54
54
  import { body, paint, section } from "../output.js";
55
- import { outsideScope } from "../outside-scope.js";
55
+ import { consumersOf, outsideScope, } from "../outside-scope.js";
56
56
  import { phase, startProgress } from "../progress.js";
57
57
  import { repoStateOf } from "../repo-state.js";
58
58
  import { runtimeDeclaredVars, runtimeDeclaredVarsIn } from "../runtime-vars.js";
@@ -477,10 +477,11 @@ export async function takeCensus(root, opts) {
477
477
  const repoRoot = scopeLabel && root.endsWith(join(sep, ...scopeLabel.split("/")))
478
478
  ? root.slice(0, root.length - scopeLabel.length - 1)
479
479
  : root;
480
- const outside = await outsideScope(repoRoot, [
481
- ...(scopeLabel ? [scopeLabel] : []),
482
- ...(opts?.usage ?? []).map((u) => u.label),
483
- ]).catch(() => null);
480
+ /**
481
+ * A MEDIÇÃO EM SI ACONTECE MAIS ABAIXO, quando o nome do pacote do escopo já foi lido - ver
482
+ * `scopePkg`. É ele que diz quem CONSOME a biblioteca, e sem essa resposta a linha do fora
483
+ * nomeia os lugares sem design e cala sobre os dois apps onde ele escreve tela.
484
+ */
484
485
  /**
485
486
  * THE LIVE CATALOGUE, once, before anything is matched.
486
487
  *
@@ -2494,6 +2495,23 @@ export async function takeCensus(root, opts) {
2494
2495
  * terceiro, e os dois casos são nós já nomeados. Sem eles a fila cobraria trabalho que não
2495
2496
  * existe, que é exatamente o defeito que ela vem medir.
2496
2497
  */
2498
+ /**
2499
+ * O QUE O ESCOPO DEIXOU DE FORA, e quem continua governado apesar de estar fora dele.
2500
+ *
2501
+ * Aqui e não lá em cima porque a resposta depende de `scopePkg`: os consumidores são quem
2502
+ * DECLARA depender desta biblioteca, e a saída deles é a lista que ele escreveu no config.
2503
+ */
2504
+ const outside = await outsideScope(repoRoot, [
2505
+ ...(scopeLabel ? [scopeLabel] : []),
2506
+ ...(opts?.usage ?? []).map((u) => u.label),
2507
+ ], {
2508
+ consumers: [
2509
+ ...(opts?.usage ?? []).map((u) => u.label),
2510
+ ...(await consumersOf(repoRoot, scopePkg).catch(() => [])),
2511
+ ],
2512
+ ungoverned: (await readProjectConfig(repoRoot).catch(() => null))
2513
+ ?.ungoverned,
2514
+ }).catch(() => null);
2497
2515
  const naming = namingQueue(looks, (name) => {
2498
2516
  const c = components.find((x) => x.name === name && !x.from);
2499
2517
  const target = c?.canonical ?? (c?.bucket === "exclusive" ? c.name : null);
@@ -2824,7 +2842,12 @@ export async function takeCensus(root, opts) {
2824
2842
  values: d.findings.length,
2825
2843
  named: d.named,
2826
2844
  tokenUses: d.tokenUses,
2827
- coverage: d.coverage,
2845
+ /**
2846
+ * O PERCENTUAL SÓ EXISTE QUANDO HOUVE O QUE MEDIR - ver `Reach` em `scan.ts`. Um repositório
2847
+ * sem um único valor de design não tira 100: o campo simplesmente não sai, e quem lê o censo
2848
+ * distingue "não veio do sistema" de "não havia o que contar".
2849
+ */
2850
+ ...(d.reach.measured ? { coverage: d.reach.percent } : {}),
2828
2851
  ownUses: d.ownUses,
2829
2852
  phantomUses: d.phantomUses,
2830
2853
  },
@@ -386,10 +386,24 @@ async function checkFile(root, path) {
386
386
  if (reports.length === 0)
387
387
  return fromContract(`Nothing readable at ${path}.`);
388
388
  const d = diagnose(reports);
389
- const out = [
390
- `${table.name ?? table.slug}: ${d.coverage}% of design values come from the system.`,
391
- `${d.tokenUses} from the system, ${d.findings.length} written by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}.`,
392
- ];
389
+ /**
390
+ * `0%` DEIXOU DE RESPONDER POR TRÊS COISAS - ver `Reach` em `scan.ts`.
391
+ *
392
+ * Esta linha dizia `0% of design values come from the system` tanto para um arquivo que escreve
393
+ * tudo à mão quanto para um onde a régua não tinha o que ler. Medido em 12/09 no `codelevel`:
394
+ * `CapabilityCard.tsx` tem 0 usos e 0 achados, e a mesma conta interna que aqui devolvia `0%`
395
+ * devolvia `100%` no relatório do projeto inteiro. Agora a frase é outra, e ela é a verdade
396
+ * daquele arquivo em vez de uma nota que ninguém consegue conferir.
397
+ */
398
+ const out = d.reach.measured
399
+ ? [
400
+ `${table.name ?? table.slug}: ${d.reach.percent}% of design values come from the system - ${d.reach.uses} of ${d.reach.of}.`,
401
+ `${d.tokenUses} from the system, ${d.findings.length} written by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}.`,
402
+ ]
403
+ : [
404
+ `${table.name ?? table.slug}: ${d.reach.why} here, so there is no coverage to report - not 0%, and not 100%.`,
405
+ "Nothing in this file writes a colour, a length, a font or a shadow.",
406
+ ];
393
407
  if (d.findings.length > 0) {
394
408
  out.push("", "Written by hand:");
395
409
  for (const f of d.findings.slice(0, 40)) {
@@ -335,6 +335,23 @@ export async function upgrade(asked, opts) {
335
335
  }
336
336
  if (!opts.force) {
337
337
  console.log(`✓ ${slug} is already at the latest version (v${installed}).`);
338
+ /**
339
+ * A FIAÇÃO É REFEITA AQUI TAMBÉM - e a falta disto era um comando que mandava rodar ELE
340
+ * MESMO (dono, 12/09).
341
+ *
342
+ * `rewireIfBehind` só era chamado dentro do ramo de rematerialização, ou seja: só quando a
343
+ * PASTA estava atrasada. A pasta e o hook são dois lugares diferentes e ficam defasados por
344
+ * motivos diferentes - `align` já os trata como dois fatos, e o comentário dele diz por quê:
345
+ * *"um alarme desligado por um conserto parcial é pior que alarme nenhum"*. Faltava o outro
346
+ * lado da mesma moeda: um CONSERTO parcial que desliga nada.
347
+ *
348
+ * O que ele viu: pasta em dia, hook pinado em 0.16.416, CLI rodando 0.16.419. O `upgrade`
349
+ * imprimia *"already at the latest version"* e saía, e o alinhamento seguinte recomendava
350
+ * `npx synthesisui upgrade` - o comando que ele tinha acabado de rodar.
351
+ *
352
+ * `wireAgent` é um merge idempotente: quando já está na versão certa, não escreve nada.
353
+ */
354
+ await rewireIfBehind(root, opts.cli);
338
355
  /**
339
356
  * E AQUI TAMBEM, que e' o caminho mais percorrido de todos: sem gap de versao, este comando
340
357
  * dizia uma linha e sumia. Quem editou tres componentes ficava sem saber se eles seguem sendo
package/dist/config.js CHANGED
@@ -163,6 +163,20 @@ export async function readProjectConfig(root) {
163
163
  ...(parsed.absorb === "code" || parsed.absorb === "system"
164
164
  ? { absorb: parsed.absorb }
165
165
  : {}),
166
+ /**
167
+ * O QUE ELE TIROU DA GOVERNANÇA - ver `ProjectConfig.ungoverned`.
168
+ *
169
+ * Sem o campo, a lista é vazia e tudo que consome a biblioteca segue governado. Entradas que
170
+ * não são caminho são descartadas em silêncio: um config meio escrito não pode governar mais
171
+ * do que o dono dele pediu, e também não pode derrubar o comando.
172
+ */
173
+ ...(Array.isArray(parsed.ungoverned)
174
+ ? {
175
+ ungoverned: parsed.ungoverned
176
+ .filter((x) => typeof x === "string" && !!x.trim())
177
+ .map((x) => x.trim().replace(/^\.\//, "").replace(/\/$/, "")),
178
+ }
179
+ : {}),
166
180
  };
167
181
  }
168
182
  catch {