synthesisui 0.16.469 → 0.16.471

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.
@@ -20,6 +20,7 @@ import { detectStack } from "../stack.js";
20
20
  import { onlyWhatMatched } from "../their-theme.js";
21
21
  import { tongueOf } from "../their-tongue.js";
22
22
  import { pointTokensAtTheirNames } from "../their-vars.js";
23
+ import { resolveDeps } from "../stack.js";
23
24
  import { tracksAnyOf } from "../tracked.js";
24
25
  /**
25
26
  * QUAL METADE DESTA PASTA UM TIME COMMITA.
@@ -177,6 +178,43 @@ const exists = (path) => access(path).then(() => true, () => false);
177
178
  * eram duas leituras, o terminal dizia a verdade (só citava para quem tem) e a escrita plantava o
178
179
  * arquivo em todo mundo - a pior combinação, porque a prosa parecia certa.
179
180
  */
181
+ /**
182
+ * O PROJETO USA TAILWIND? - pela dependência, ou pela folha de entrada que o importa (`@import "tailwindcss"`,
183
+ * `@tailwind base`). Só o que a frase do alinhamento precisa: ela não existe num projeto sem Tailwind (28/09).
184
+ */
185
+ async function familiesHeNames(root, families) {
186
+ const { access } = await import("node:fs/promises");
187
+ const imported = await access(join(root, "_synthesisui", "census.json")).then(() => true, () => false);
188
+ if (!imported)
189
+ return families;
190
+ const { readTheirCss } = await import("../doctor/requests.js");
191
+ const css = (await readTheirCss(root)).toLowerCase();
192
+ if (!css.trim())
193
+ return families;
194
+ const kept = Object.entries(families).filter(([, v]) => {
195
+ if (typeof v !== "string")
196
+ return true;
197
+ const first = v.split(",")[0]?.trim().replace(/^["']|["']$/g, "") ?? "";
198
+ return !first || /^var\(/.test(first) || css.includes(first.toLowerCase());
199
+ });
200
+ return Object.fromEntries(kept);
201
+ }
202
+ async function projectWearsTailwind(root) {
203
+ const deps = await resolveDeps(root).catch(() => ({}));
204
+ if (Object.keys(deps).some((d) => d === "tailwindcss" || d.startsWith("@tailwindcss/")))
205
+ return true;
206
+ const { readdir, readFile } = await import("node:fs/promises");
207
+ for (const dir of ["", "app", "src", "src/app", "styles", "src/styles"]) {
208
+ for (const name of await readdir(join(root, dir)).catch(() => [])) {
209
+ if (!/\.(css|scss)$/i.test(name))
210
+ continue;
211
+ const text = await readFile(join(root, dir, name), "utf8").catch(() => "");
212
+ if (/@import\s+["']tailwindcss["']|@tailwind\s+(base|components|utilities)/.test(text))
213
+ return true;
214
+ }
215
+ }
216
+ return false;
217
+ }
180
218
  async function wearsShadcn(root) {
181
219
  return access(join(root, "components.json")).then(() => true, () => false);
182
220
  }
@@ -591,7 +629,13 @@ export async function add(slug, opts) {
591
629
  * A linha diz quantos utilitários do Tailwind ficaram apontando para a decisão dele e quantos
592
630
  * saíram por serem nossos, com os primeiros nomes. Quem lê pode discordar de qualquer um.
593
631
  */
594
- if (!opts.themeFollows && (aligned.dropped.length > 0 || aligned.own.length > 0)) {
632
+ /**
633
+ * A FRASE DO TAILWIND SÓ EXISTE ONDE HÁ TAILWIND - rodadas 12 a 14 do refinamento, 28/09. "Your code declares no
634
+ * Tailwind names of its own yet" saía no Vite e no Sass, que não têm Tailwind, e no Tailwind com 24 nomes no
635
+ * `@theme` - vinte linhas depois de "100% of your 59 style decisions carry your own vocabulary".
636
+ */
637
+ const wearsTailwind = await projectWearsTailwind(projectRoot);
638
+ if (wearsTailwind && !opts.themeFollows && (aligned.dropped.length > 0 || aligned.own.length > 0)) {
595
639
  console.log(line(
596
640
  /**
597
641
  * A LINHA DIZ QUE FICARAM DE FORA - e nao AFIRMA por que, porque ela nao sabe.
@@ -614,7 +658,7 @@ export async function add(slug, opts) {
614
658
  * Com ZERO, a frase falava de decisões que um projeto novo não tem - rodada 1 do refinamento,
615
659
  * 27/09: "0 Tailwind utilities now point at your own decisions", num Next recém-criado.
616
660
  */
617
- ` ${aligned.kept > 0 ? `${aligned.kept} Tailwind utilit${aligned.kept === 1 ? "y" : "ies"} now point at your own decisions` : "Your code declares no Tailwind names of its own yet"}${aligned.own.length > 0 ? `; ${aligned.own.length} more name${aligned.own.length === 1 ? "s a utility" : " utilities"} only this system has (${aligned.own.slice(0, 3).join(", ")}${aligned.own.length > 3 ? ", …" : ""}), so they are declared here` : ""}${aligned.dropped.length > 0 ? `; ${aligned.dropped.length} were left as ${aligned.dropped.length === 1 ? "it is" : "they are"} (${aligned.dropped.slice(0, 3).join(", ")}${aligned.dropped.length > 3 ? ", …" : ""}) - your classes keep meaning what they mean today` : ""}.`));
661
+ ` ${aligned.kept > 0 ? `${aligned.kept} Tailwind utilit${aligned.kept === 1 ? "y" : "ies"} now point at your own decisions` : "None of your Tailwind names needed re-pointing - they already say what they mean"}${aligned.own.length > 0 ? `; ${aligned.own.length} more name${aligned.own.length === 1 ? "s a utility" : " utilities"} only this system has (${aligned.own.slice(0, 3).join(", ")}${aligned.own.length > 3 ? ", …" : ""}), so they are declared here` : ""}${aligned.dropped.length > 0 ? `; ${aligned.dropped.length} were left as ${aligned.dropped.length === 1 ? "it is" : "they are"} (${aligned.dropped.slice(0, 3).join(", ")}${aligned.dropped.length > 3 ? ", …" : ""}) - your classes keep meaning what they mean today` : ""}.`));
618
662
  }
619
663
  /**
620
664
  * O CICLO RECUSADO É DITO, e não engolido - senão o cliente lê um número menor sem saber por quê.
@@ -661,9 +705,18 @@ export async function add(slug, opts) {
661
705
  * ausente do diretório de trabalho, o que ninguém faz de propósito. A linha passa a dizer isso e o
662
706
  * comando de volta.
663
707
  */
708
+ /**
709
+ * "updated" SÓ QUANDO MUDOU - rodada 12, 28/09. Sem agente ligado, nada é escrito (a escolha dele autoriza cada
710
+ * casa), e a linha dizia "CLAUDE.md updated - your file is intact, with our block inside it" sobre um arquivo que
711
+ * o `git diff` mostrava intocado.
712
+ */
664
713
  console.log(claudeMd.created
665
714
  ? ` CLAUDE.md created - there was none here (${claudeMd.count} system(s) indexed). If your repo has one in git, it is missing from your working tree: \`git checkout -- CLAUDE.md\` and run this again to keep both.`
666
- : ` CLAUDE.md updated - your file is intact, with our block inside it (${claudeMd.count} system(s) indexed)`);
715
+ : claudeMd.changed.length > 0
716
+ ? ` ${claudeMd.changed.join(" and ")} updated - your file is intact, with our block inside it (${claudeMd.count} system(s) indexed)`
717
+ : (await agentsToMaintain(projectRoot)).length === 0
718
+ ? " your agent's instructions were not touched - no agent is connected here yet. `npx synthesisui connect` writes the block"
719
+ : ` CLAUDE.md already current - nothing to rewrite (${claudeMd.count} system(s) indexed)`);
667
720
  if (opts.setupHints === false)
668
721
  return;
669
722
  // ── DX: concrete paths + copy-pasteable snippets, with breathing room ──
@@ -704,7 +757,12 @@ export async function add(slug, opts) {
704
757
  // Next apps get fonts.ts MATERIALIZED (deterministic does, not teaches):
705
758
  // next/font = self-hosted + preloaded + adjusted fallback, no FOUT
706
759
  // "blink". The Google Fonts <link> stays as the framework-agnostic path.
707
- const families = payload.document.foundations.typography.families;
760
+ /**
761
+ * NUM SISTEMA IMPORTADO, SÓ AS FAMÍLIAS QUE O CÓDIGO DELE NOMEIA - rodada 13, 28/09: o link pedia JetBrains Mono,
762
+ * a mono da semente, num projeto que só declara Inter e Lora. Num fork ou num projeto novo (sem censo), as
763
+ * fontes do sistema escolhido são as dele e continuam todas.
764
+ */
765
+ const families = await familiesHeNames(projectRoot, payload.document.foundations.typography.families);
708
766
  const fontsHref = googleFontsHref(families);
709
767
  /**
710
768
  * OS PESOS QUE AS FAMÍLIAS TÊM NO PROJETO DELE - ver `next-font-weights.ts`.
@@ -712,7 +770,15 @@ export async function add(slug, opts) {
712
770
  * Lido UMA vez e passado adiante: o manifesto tem 1942 famílias, e reabri-lo por app de um
713
771
  * monorepo seria a mesma leitura repetida com liberdade de discordar de si mesma.
714
772
  */
715
- const fontPlan = projectConfig.target === "next"
773
+ /**
774
+ * O next/font SÓ ONDE HÁ NEXT - rodada 13, 28/09: num projeto Vite o `add` dizia "next/font: app/fonts.ts was NOT
775
+ * written - this project's next/font manifest is not readable" e mandava editar `app/layout.tsx`. O `target` do
776
+ * config nasce `next` por padrão; a dependência é o fato.
777
+ */
778
+ const stackDeps = await resolveDeps(projectRoot).catch(() => ({}));
779
+ const onNext = projectConfig.target === "next" && "next" in stackDeps;
780
+ const headHome = onNext ? "app/layout.tsx" : "vite" in stackDeps ? "index.html" : "your root HTML";
781
+ const fontPlan = onNext
716
782
  ? nextFontSnippet({
717
783
  families,
718
784
  appDir,
@@ -836,7 +902,7 @@ export async function add(slug, opts) {
836
902
  }
837
903
  else if (fontsHref) {
838
904
  console.log("");
839
- console.log(line("3. Load the type - this system ships font NAMES, not the fonts. Add to your app's <head> (e.g. app/layout.tsx) so the families resolve (else they fall back and the look is lost):"));
905
+ console.log(line(`3. Load the type - this system ships font NAMES, not the fonts. Add to your app's <head> (e.g. ${headHome}) so the families resolve (else they fall back and the look is lost):`));
840
906
  console.log("");
841
907
  console.log(snippet([
842
908
  `<link rel="preconnect" href="https://fonts.googleapis.com" />`,
@@ -844,7 +910,7 @@ export async function add(slug, opts) {
844
910
  `<link rel="stylesheet" href="${fontsHref}" />`,
845
911
  ]));
846
912
  console.log("");
847
- console.log(line(` Prefer next/font or self-hosting? Fine - just register these exact families: ${customFontFamilies(families).join(", ")}.`));
913
+ console.log(line(` Prefer ${onNext ? "next/font or " : ""}self-hosting? Fine - just register these exact families: ${customFontFamilies(families).join(", ")}.`));
848
914
  }
849
915
  console.log(section("Next"));
850
916
  // FIRST, and before anything that assumes the setup worked. The block above
@@ -23,6 +23,24 @@ import { SKILLS } from "../skills.js";
23
23
  * `null` para censo que não diz quem o mediu: um censo assim não prova nada, e supor que ele é
24
24
  * velho faria toda pessoa que ainda não re-mediu ver o aviso para sempre.
25
25
  */
26
+ /**
27
+ * O CENSO DIZ QUE MEDIU O REPOSITÓRIO INTEIRO - `measured` com a procedência e nenhum `scope`, que por contrato é
28
+ * "o repositório inteiro" (`Census.scope`). Sem isto, todo import sem `--scope` terminava em "does not record where
29
+ * it was measured" sobre um sistema que tinha vindo dali (rodadas 11 a 14, 28/09).
30
+ */
31
+ async function wholeRepoMeasured(root) {
32
+ const raw = await readFile(join(root, "_synthesisui", "census.json"), "utf8").catch(() => null);
33
+ if (!raw)
34
+ return false;
35
+ try {
36
+ const c = JSON.parse(raw);
37
+ const narrowed = (typeof c.scope === "string" && c.scope.trim() !== "") || (Array.isArray(c.scopes) && c.scopes.length > 0);
38
+ return c.measured != null && typeof c.measured === "object" && !narrowed;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
26
44
  async function censusMeasuredBy(root) {
27
45
  const raw = await readFile(join(root, "_synthesisui", "census.json"), "utf8").catch(() => null);
28
46
  if (!raw)
@@ -210,7 +228,7 @@ opts = {}) {
210
228
  * sistema de 36).
211
229
  */
212
230
  const scope = await measuredScope(root, lock.slug);
213
- if (!scope.system && scope.usage.length === 0 && lock.reading !== "none")
231
+ if (!scope.system && scope.usage.length === 0 && lock.reading !== "none" && !(await wholeRepoMeasured(root)))
214
232
  out.push({
215
233
  says: `"${lock.slug}" does not record where it was measured, so a re-measure would read this whole repo instead of the folder the system came from. Sync asks once and remembers the answer.`,
216
234
  /**
@@ -278,7 +278,9 @@ export async function component(slug, name, opts) {
278
278
  * A saída oferecida é a mesma que o `upgrade` oferece, e é a que ele já tem na mão: `--force`.
279
279
  */
280
280
  const slugDir = join(root, "_synthesisui", "ds", slug);
281
- const editedHere = await editedSinceWritten((await readWritten(slugDir))[local], join(root, config.componentsDir, local));
281
+ const editedHere = await editedSinceWritten((await readWritten(slugDir))[local], join(root, config.componentsDir, local),
282
+ /** ele está pedindo o componente: o arquivo que apagou não é trabalho dele a proteger */
283
+ { missingIsEdit: false });
282
284
  if (editedHere && editedHere.length > 0 && !opts.force) {
283
285
  console.log(section("You edited this one"));
284
286
  console.log(body(`${editedHere.join(", ")} in ${config.componentsDir}/${local} changed after we wrote ${editedHere.length === 1 ? "it" : "them"} - kept YOUR file(s).`));
@@ -28,10 +28,13 @@ export async function gaps(opts) {
28
28
  let ledger;
29
29
  /** A régua única - ver `describeValueRuler`. Ausente num censo medido antes de 0.16.308. */
30
30
  let values;
31
+ /** A cobertura por componente - existe em todo estilo, inclusive os que não vestem classe. */
32
+ let coverage;
31
33
  try {
32
34
  const census = JSON.parse(raw);
33
35
  ledger = census.ledger;
34
36
  values = census.values?.classes ?? undefined;
37
+ coverage = census.coverage ?? undefined;
35
38
  }
36
39
  catch {
37
40
  console.log(body(`${path} is not readable JSON.`));
@@ -47,7 +50,7 @@ export async function gaps(opts) {
47
50
  return;
48
51
  }
49
52
  if (opts.brief)
50
- return brief(ledger, values);
53
+ return brief(ledger, values, coverage);
51
54
  for (const line of describeTriage(triageLedger(ledger, opts.cli), values))
52
55
  console.log(body(line));
53
56
  /**
@@ -111,10 +114,23 @@ export async function gaps(opts) {
111
114
  * resolver o valor que carrega dois nomes dele. Um link sem consequência dita é um link que
112
115
  * espera para sempre.
113
116
  */
114
- async function brief(ledger, values) {
117
+ async function brief(ledger, values, coverage) {
115
118
  const places = ledger.unread.length;
116
- if (!values)
119
+ /**
120
+ * SEM A CONTA POR CLASSE, A COBERTURA POR COMPONENTE - rodadas 8 e 9, 27/09.
121
+ *
122
+ * `values` só existe quando algum componente veste classe. CSS Modules, `style={{ }}` e SCSS não
123
+ * vestem, e o `brief` saía com o título e mais nada. `coverage` existe nos três, e é a mesma
124
+ * pergunta ("quanto do que você escreveu chegou") numa unidade que eles têm.
125
+ */
126
+ if (!values) {
127
+ console.log(bodyWrapped(coverage && coverage.components > 0
128
+ ? `We read ${paint.blue(paint.strong(`${coverage.read} of your ${coverage.components} components`))} - ${coverage.declarations} style ${coverage.declarations === 1 ? "declaration" : "declarations"} between them.`
129
+ : "This measurement carries no coverage number, so there is none to show rather than a zero.").join("\n"));
130
+ if (places > 0)
131
+ console.log(bodyWrapped(paint.dim(`What we did not read sits in ${places} ${places === 1 ? "place" : "places"} - \`synthesisui gaps\` lists each one with the file and the line.`)).join("\n"));
117
132
  return;
133
+ }
118
134
  const decisions = values.seen - values.structure;
119
135
  const closed = values.interpreted + values.answered;
120
136
  const pct = decisions > 0 ? Math.round((closed / decisions) * 100) : 0;
@@ -582,16 +582,43 @@ round) {
582
582
  said.push(...refusals);
583
583
  block = true;
584
584
  }
585
- if (rest.length > 0)
585
+ if (rest.length > 0) {
586
+ /**
587
+ * OS QUE PASSARAM DO CORTE SÃO CHECADOS TAMBÉM - rodadas 12 a 14 do refinamento, 28/09.
588
+ *
589
+ * A frase era "were not checked here", e o agente ia conferir a pasta à mão em todo turno. O relatório
590
+ * continua em três - trinta numa resposta é o ruído que faz alguém desinstalar -, mas a varredura dos outros
591
+ * é barata, e o que ela acha sai como UMA linha: os que escrevem valor à mão, pelo nome, ou "nothing to name".
592
+ */
593
+ const { table } = await loadSystem(root).catch(() => ({ table: null }));
594
+ const dirty = [];
595
+ let measured = 0;
596
+ for (const { rel } of rest.slice(0, REFUSAL_CEILING)) {
597
+ const src = table ? await readFile(join(root, rel), "utf8").catch(() => null) : null;
598
+ if (src == null || !table)
599
+ continue;
600
+ measured += 1;
601
+ const n = diagnose([scanSource(rel, src, table)]).findings.length;
602
+ if (n > 0)
603
+ dirty.push(`${rel} (${n} by hand)`);
604
+ }
605
+ const unmeasured = rest.length - measured;
586
606
  said.push([
587
- `${rest.length} more file${rest.length === 1 ? "" : "s"} changed in this ${round} and ${rest.length === 1 ? "was" : "were"} not checked here: ${rest
588
- .slice(0, 10)
589
- .map((c) => c.rel)
590
- .join(", ")}${rest.length > 10 ? `, +${rest.length - 10} more` : ""}.`,
607
+ measured === 0
608
+ ? `${rest.length} more file${rest.length === 1 ? "" : "s"} changed in this ${round} and ${rest.length === 1 ? "was" : "were"} not checked here: ${rest
609
+ .slice(0, 10)
610
+ .map((c) => c.rel)
611
+ .join(", ")}${rest.length > 10 ? `, +${rest.length - 10} more` : ""}.`
612
+ : dirty.length === 0
613
+ ? `${measured} more file${measured === 1 ? "" : "s"} changed in this ${round}, checked too - nothing to name.${unmeasured > 0 ? ` ${unmeasured} more past the ceiling were not read.` : ""}`
614
+ : `${measured} more file${measured === 1 ? "" : "s"} changed in this ${round}, checked too: ${dirty
615
+ .slice(0, 10)
616
+ .join(", ")}${dirty.length > 10 ? `, +${dirty.length - 10} more` : ""}.${unmeasured > 0 ? ` ${unmeasured} more past the ceiling were not read.` : ""}`,
591
617
  round === "turn"
592
618
  ? "Run `npx synthesisui doctor` to see them - they are checked again the next turn one of them changes."
593
619
  : "Run `npx synthesisui doctor` to see them, or write one of them again and this will check it.",
594
620
  ].join("\n"));
621
+ }
595
622
  return { said, block };
596
623
  }
597
624
  /**
@@ -9,13 +9,13 @@ import { craftLines } from "../baseline-craft.js";
9
9
  import { PAGES_MAX, pageCompositionOf } from "../census-pages.js";
10
10
  import { readCredentials, readProjectConfig, readToken, resolveRegistry, sameRegistry, } from "../config.js";
11
11
  import { declaredElsewhere } from "../declared-elsewhere.js";
12
- import { architectureGap, architectureRule, componentHome, describeArchitecture, describeChoice, describeGap, detectArchitectures, homeLine, packagingOf, proposeNewHome, resolvesAs, } from "../doctor/architecture.js";
12
+ import { architectureGap, architectureRule, componentHome, describeArchitecture, describeChoice, describeGap, detectArchitectures, homeLine, packagingOf, proposeNewHome, resolvesAs, countsAsOrganization, } from "../doctor/architecture.js";
13
13
  import { findBrokenRefs } from "../doctor/broken-refs.js";
14
14
  import { nestingRules, propRules, readDefinitionProps, readNesting, readRuntime, } from "../doctor/call-sites.js";
15
15
  import { asCatalogueTable, describeFallback, fetchCatalogue, } from "../doctor/catalogue-fetch.js";
16
16
  import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
17
17
  import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
18
- import { describeGate, gateComponent, groupSkips, SCREENS_FOR_SYSTEM, } from "../doctor/component-gate.js";
18
+ import { describeGate, gateComponent, groupSkips, SCREENS_FOR_SYSTEM, livesInSharedFolder, } from "../doctor/component-gate.js";
19
19
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
20
20
  import { countShape, describeCoverage, summarizeCoverage, } from "../doctor/coverage.js";
21
21
  import { classifyAside, crosswalk, floorSize, isLibrary, observedRules, useLiveCatalogue, } from "../doctor/crosswalk.js";
@@ -781,7 +781,7 @@ export async function takeCensus(root, opts) {
781
781
  * número que a organização declara é o que ele confere na mão, então ele conta o que ele
782
782
  * conta quando confere.
783
783
  */
784
- if (/\.(tsx|jsx|vue|svelte)$/i.test(rel) && !SHOWS_A_COMPONENT.test(rel)) {
784
+ if (countsAsOrganization(rel) && !SHOWS_A_COMPONENT.test(rel)) {
785
785
  const parts = rel.split("/");
786
786
  // Every ancestor learns the name of the child directly under it, which is all
787
787
  // the shape detector needs and is one pass rather than a tree walk.
@@ -1538,7 +1538,8 @@ export async function takeCensus(root, opts) {
1538
1538
  continue;
1539
1539
  }
1540
1540
  const screens = screensOf.get(d.name)?.size ?? 0;
1541
- if (screens >= SCREENS_FOR_SYSTEM) {
1541
+ /** A pasta compartilhada é compartilhada por onde mora - ver `livesInSharedFolder` (r11, 28/09). */
1542
+ if (screens >= SCREENS_FOR_SYSTEM || livesInSharedFolder(d.file)) {
1542
1543
  keep.push(d);
1543
1544
  continue;
1544
1545
  }
@@ -4990,6 +4991,8 @@ export async function runImport(opts) {
4990
4991
  await writeFile(out, `${JSON.stringify(census, null, 2)}\n`, "utf8");
4991
4992
  const token = await readToken();
4992
4993
  if (!token) {
4994
+ /** A medição está salva, e o sistema não existe: quem chamou não pode ler "enviado". */
4995
+ process.exitCode = 1;
4993
4996
  console.log("");
4994
4997
  console.log(body("To turn this into a system on your account:"));
4995
4998
  console.log(body(` ${paint.strong("synthesisui login")}`));
@@ -5002,6 +5005,7 @@ export async function runImport(opts) {
5002
5005
  // a 401 that describes the wrong problem entirely.
5003
5006
  const creds = await readCredentials();
5004
5007
  if (creds && !sameRegistry(creds.registry, base)) {
5008
+ process.exitCode = 1;
5005
5009
  console.log("");
5006
5010
  console.log(body(`You are logged in to ${paint.strong(creds.registry ?? "another registry")}, and this would go to ${paint.strong(base)}.`));
5007
5011
  console.log(body(`Your measurement is saved at ${out}. Either name the one you meant:`));
@@ -5055,6 +5059,14 @@ export async function runImport(opts) {
5055
5059
  }),
5056
5060
  }).catch(() => null);
5057
5061
  if (!res || !res.ok) {
5062
+ /**
5063
+ * NADA FOI CRIADO, e o código de saída tem que dizer isso - rodadas 6 a 10, 27/09.
5064
+ *
5065
+ * Os quatro ramos abaixo (sessão vencida, limite do plano, recusa, rede) terminavam em `return`
5066
+ * e o processo saía com 0. Um agente que confia no código de saída lia "enviado". Fica aqui, uma
5067
+ * vez, antes dos ramos, para que um ramo novo não nasça esquecendo.
5068
+ */
5069
+ process.exitCode = 1;
5058
5070
  const refusal = res
5059
5071
  ? await res
5060
5072
  .json()
@@ -5122,7 +5134,12 @@ export async function runImport(opts) {
5122
5134
  if (inheritedHere > 0) {
5123
5135
  console.log(body(`${inheritedHere} form${inheritedHere === 1 ? "" : "s"} your team already taught the pipeline apply here too - the next measurement reads them without you declaring anything.`));
5124
5136
  }
5125
- console.log(body(`${paint.strong(payload?.name ?? "Your system")} - v1 mirrors your tokens exactly, nothing improved yet.`));
5137
+ console.log(body(
5138
+ /**
5139
+ * NÃO "ESPELHA EXATAMENTE" - rodadas 11 e 13, 28/09: a v1 trazia um `display` de 4rem e uma mono JetBrains que
5140
+ * o projeto nunca declarou, e a frase dizia o contrário. As fundações vêm da semente onde ele não nomeia nada.
5141
+ */
5142
+ `${paint.strong(payload?.name ?? "Your system")} - v1 is your tokens, nothing improved yet; where your code names nothing, it keeps the starting point's value (listed in the report).`));
5126
5143
  /**
5127
5144
  * ONDE ELE NASCEU, dito pela plataforma e não pelo que foi pedido.
5128
5145
  *
@@ -385,7 +385,19 @@ async function checkFile(root, path) {
385
385
  }
386
386
  if (reports.length === 0)
387
387
  return fromContract(`Nothing readable at ${path}.`);
388
- const d = diagnose(reports);
388
+ /** As folhas do projeto que declaram nomes - só como vocabulário, ver `diagnose`. */
389
+ const seen = new Set(reports.map((r) => r.file));
390
+ const context = [];
391
+ for await (const file of walkAll([root])) {
392
+ const rel = relative(root, file);
393
+ if (seen.has(rel) || !/\.(css|scss|sass|less)$/i.test(rel))
394
+ continue;
395
+ const src = await readFile(file, "utf8").catch(() => "");
396
+ const report = src ? scanSource(rel, src, table) : null;
397
+ if (report?.ownDeclared?.length)
398
+ context.push(report);
399
+ }
400
+ const d = diagnose(reports, context);
389
401
  /**
390
402
  * `0%` DEIXOU DE RESPONDER POR TRÊS COISAS - ver `Reach` em `scan.ts`.
391
403
  *
@@ -51,6 +51,30 @@ export async function status(opts) {
51
51
  // sem _synthesisui: tratado abaixo
52
52
  }
53
53
  console.log(section("Status"));
54
+ /**
55
+ * A CONTA, ANTES DE TUDO - rodadas 6 a 10, 27/09. Este comando só falava dela quando NÃO havia
56
+ * login, e o agente ia ler `~/.synthesisui/credentials.json` para saber em qual conta estava. O
57
+ * arquivo não guarda conta nenhuma: quem sabe é o servidor, pelo mesmo bearer. O token não aparece.
58
+ */
59
+ const token = await readToken();
60
+ const base = resolveRegistry(opts.registry);
61
+ if (!token) {
62
+ console.log(body(paint.dim("Not logged in, so this is the local half. `synthesisui login` adds what the platform knows.")));
63
+ }
64
+ else {
65
+ const who = await fetch(`${base}/api/auth/get-session`, {
66
+ headers: { Authorization: `Bearer ${token}` },
67
+ })
68
+ .then(async (r) => r.ok ? (await r.json()) : null)
69
+ .then((j) => ({ reached: true, email: j?.user?.email ?? null }))
70
+ .catch(() => ({ reached: false, email: null }));
71
+ console.log(body(who.email
72
+ ? `Logged in as ${paint.strong(who.email)} on ${base}.`
73
+ : who.reached
74
+ ? `The saved login is no longer accepted by ${base}. \`synthesisui login\` signs in again.`
75
+ : `Logged in on ${base}, and the account could not be confirmed - the registry did not answer.`));
76
+ }
77
+ console.log("");
54
78
  if (installed.length === 0) {
55
79
  /**
56
80
  * NADA INSTALADO NÃO SIGNIFICA NADA A OFERECER, e mandar `list` era o conselho da outra pessoa.
@@ -128,13 +152,8 @@ export async function status(opts) {
128
152
  * A ÚNICA PERGUNTA QUE O DISCO NÃO SABE: existe medição mais nova do que a minha? Sem login isso
129
153
  * simplesmente não é dito - e não é um erro, porque tudo acima continua verdadeiro.
130
154
  */
131
- const token = await readToken();
132
- if (!token) {
133
- console.log("");
134
- console.log(body(paint.dim("Not logged in, so this is the local half. `synthesisui login` adds what the platform knows.")));
155
+ if (!token)
135
156
  return;
136
- }
137
- const base = resolveRegistry(opts.registry);
138
157
  const owned = installed.find((ds) => !ds.adopted);
139
158
  if (!owned)
140
159
  return;
@@ -13,6 +13,7 @@ import { measuredScope, rememberScope } from "../measured-scope.js";
13
13
  import { fromCensus } from "../memory/observation.js";
14
14
  import { reportMeasurement } from "../memory/report.js";
15
15
  import { mergeCensus } from "../merge-census.js";
16
+ import { describeGrowth, whatGrew } from "../what-grew.js";
16
17
  import { body, paint, section, snippet } from "../output.js";
17
18
  import { repoStateOf } from "../repo-state.js";
18
19
  import { reportWhatIsLeft } from "./align.js";
@@ -602,6 +603,26 @@ export async function remeasure(args) {
602
603
  return null;
603
604
  }
604
605
  }
606
+ /**
607
+ * O QUE O CÓDIGO DELE GANHOU, ANTES DE IR - itens 4 e 4b da triagem das rodadas 11 a 14 (28/09).
608
+ *
609
+ * *"Componente novo nunca entra nem sai do sistema calado"*, e passa pela engine: cada um que nasceu desde o último
610
+ * envio vem com a medida de cor e espaçamento (a régua do `doctor`), e o que sumiu do código é nomeado. Num
611
+ * terminal, a pessoa diz se vai; sem terminal (o agente), vai, e a lista fica na saída para ele repassar.
612
+ */
613
+ {
614
+ const grown = await whatGrew(root, census, (await readSyncMark(root))?.readings ?? null);
615
+ const growth = describeGrowth(grown);
616
+ if (growth.length > 0) {
617
+ console.log(section("What your code grew"));
618
+ for (const l of growth)
619
+ console.log(body(l));
620
+ if (!(await askToSendGrowth())) {
621
+ console.log(body("Nothing was sent. Your draft is untouched - run `npx synthesisui sync` again when you want them in."));
622
+ return null;
623
+ }
624
+ }
625
+ }
605
626
  /**
606
627
  * O QUE ELE MUDOU NO DESIGN SYSTEM INSTALADO, lido do disco e sem rede.
607
628
  *
@@ -908,6 +929,23 @@ export async function remeasure(args) {
908
929
  * Sem TTY a resposta é NÃO: uma invocação por script que sobrescreve trabalho de alguém porque
909
930
  * ninguém estava lá para dizer não é o pior default possível. `--yes` é o caminho explícito.
910
931
  */
932
+ /** Sem terminal, vai - o agente repassa a lista. Com terminal, a pessoa responde, e Enter é sim. */
933
+ async function askToSendGrowth() {
934
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
935
+ return true;
936
+ const { createInterface } = await import("node:readline/promises");
937
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
938
+ try {
939
+ const answer = await rl.question(" send them to your system's draft? [Y/n]: ");
940
+ return !/^n(o)?$/i.test(answer.trim());
941
+ }
942
+ catch {
943
+ return true;
944
+ }
945
+ finally {
946
+ rl.close();
947
+ }
948
+ }
911
949
  async function askToOverwrite() {
912
950
  if (!process.stdin.isTTY || !process.stdout.isTTY)
913
951
  return false;
@@ -64,6 +64,17 @@ const SHAPES = [
64
64
  describe: (a, where) => `Every component sits directly under \`${flatFolder(where, a.evidence[0])}\`, one folder deep. It is the shape that stays legible longest while a system is small, and the one to revisit when the folder passes about forty names.`,
65
65
  },
66
66
  ];
67
+ /**
68
+ * O ARQUIVO DA ROTA NÃO É COMPONENTE - rodadas 11, 12 e 14 do refinamento, 28/09.
69
+ *
70
+ * `page.tsx` e `layout.tsx` contavam como componentes na medida da organização, e um `app/` só com rotas virava
71
+ * "Components sit BESIDE THE ROUTE" - a regra ficava somada à que o dono escolheu. As convenções de arquivo do
72
+ * Next (app e pages) são do framework: dizem onde a tela mora, não onde um componente nasce.
73
+ */
74
+ const ROUTE_FILE = /(^|\/)(page|layout|loading|error|global-error|not-found|template|default|route|head|_app|_document|_error)\.(tsx|jsx|ts|js)$/i;
75
+ export function countsAsOrganization(rel) {
76
+ return /\.(tsx|jsx|vue|svelte)$/i.test(rel) && !ROUTE_FILE.test(rel);
77
+ }
67
78
  /**
68
79
  * O MESMO NOME, MÓDULO CAIXA E SEPARADOR - a única normalização deste arquivo.
69
80
  *
@@ -40,6 +40,31 @@ export const UTILITY = /\b(?:bg|text|border|p|px|py|m|mx|my|gap|flex|grid|rounde
40
40
  * would rename everything we compile on the strength of three sightings.
41
41
  */
42
42
  const MIN_GLOBAL_DECLS = 20;
43
+ /** O piso quando a folha é BEM de verdade (`__` e `--` na maioria das classes) - ver `detectClassStyle`. */
44
+ const MIN_BEM_DECLS = 6;
45
+ /**
46
+ * O BEM ANINHADO DO SASS, escrito por extenso - `.button { &--primary {} &__icon {} }` são `button--primary` e
47
+ * `button__icon`. O `CLASS_DECL` só via `.classe {` no começo da linha, e uma casa BEM em Sass quase nunca
48
+ * escreve assim (rodada 14, 28/09).
49
+ */
50
+ function nestedBem(source) {
51
+ const out = [];
52
+ const stack = [];
53
+ for (const line of source.split("\n")) {
54
+ const own = /^\s*\.([a-zA-Z][\w-]*)\s*\{/.exec(line)?.[1];
55
+ const child = /^\s*&(__|--)([\w-]+)\s*[{,:]/.exec(line);
56
+ const parent = [...stack].reverse().find((c) => c != null) ?? null;
57
+ if (child && parent)
58
+ out.push(`${parent}${child[1]}${child[2]}`);
59
+ for (const ch of line) {
60
+ if (ch === "{")
61
+ stack.push(own ?? (child && parent ? `${parent}${child[1]}${child[2]}` : null));
62
+ else if (ch === "}")
63
+ stack.pop();
64
+ }
65
+ }
66
+ return out;
67
+ }
43
68
  /**
44
69
  * Prefixes worth honouring have to be shared by most of what they declare.
45
70
  * A project with `app-header` and `admin-nav` has two words, not a prefix.
@@ -60,6 +85,8 @@ export function detectClassStyle(files) {
60
85
  // Only a non-module stylesheet can hold a class anybody else must match.
61
86
  for (const m of source.matchAll(CLASS_DECL))
62
87
  globalDecls.push(m[1]);
88
+ if (/\.(scss|sass|less)$/i.test(file))
89
+ globalDecls.push(...nestedBem(source));
63
90
  continue;
64
91
  }
65
92
  utilities += source.match(UTILITY)?.length ?? 0;
@@ -71,7 +98,14 @@ export function detectClassStyle(files) {
71
98
  // threshold with no convention between them, and we would have renamed every
72
99
  // class we compile to match vendor CSS (dono, 01/08). Many global classes with
73
100
  // nothing in common is not a house style, it is a stylesheet.
74
- if (globalDecls.length >= MIN_GLOBAL_DECLS) {
101
+ /**
102
+ * UMA CASA BEM PEQUENA TAMBÉM É CASA - rodada 14, 28/09. Quatro blocos com `--modificador` e `__parte` são
103
+ * uma convenção inequívoca, e a régua de 20 existe para a folha sem forma (o tema de syntax-highlighting
104
+ * abaixo), não para esta. A forma é exigida do mesmo jeito: só baixa o piso quando a folha é BEM de verdade.
105
+ */
106
+ const bemShaped = globalDecls.filter((c) => /__|--/.test(c)).length;
107
+ const floor = bemShaped >= MIN_BEM_DECLS && bemShaped / Math.max(1, globalDecls.length) > 0.4 ? MIN_BEM_DECLS : MIN_GLOBAL_DECLS;
108
+ if (globalDecls.length >= floor) {
75
109
  const shape = shapeOf(globalDecls);
76
110
  if (shape) {
77
111
  return {
@@ -203,8 +203,32 @@ function adapterFor(source, internal) {
203
203
  * caller passes the counts only when it has them, and absent means the test is skipped.
204
204
  */
205
205
  export const SCREENS_FOR_SYSTEM = 2;
206
+ /**
207
+ * A PASTA QUE O PRODUTO COMPARTILHA - `components/`, `ui/`, o pacote de ui de um monorepo. O que mora ali já é,
208
+ * pelo lugar, uma decisão compartilhada, e a regra das duas telas não se aplica: no r11 (28/09) ela tirou o
209
+ * Button, o Input e o Label de `components/ui` depois de três telas. O que mora DENTRO da árvore de rotas
210
+ * (`app/`, `pages/`) continua passando pela régua.
211
+ */
212
+ export function livesInSharedFolder(file) {
213
+ const parts = file.split(/[/\\]/).slice(0, -1).map((p) => p.toLowerCase());
214
+ if (parts.some((p) => p === "app" || p === "pages" || p === "routes"))
215
+ return false;
216
+ return parts.some((p) => p === "components" || p === "ui" || p === "atoms" || p === "molecules" || p === "organisms");
217
+ }
206
218
  export function gateComponent(input) {
207
219
  const { name, file, source } = input;
220
+ /**
221
+ * A RAIZ QUE O VITE (E O CREATE-REACT-APP) MONTA - `src/App.tsx` é a tela inteira, o que a `page` é no Next.
222
+ * No r13 (28/09) ela entrou no sistema como o componente `app`. Só na raiz do escopo ou de `src/`: um `App`
223
+ * dentro de uma pasta de componentes é outra coisa.
224
+ */
225
+ if (/^(src[/\\])?App\.[jt]sx?$/.test(file)) {
226
+ return {
227
+ ok: false,
228
+ why: "route",
229
+ because: `\`${file}\` is the root the app mounts - the whole screen, not a component somebody can import`,
230
+ };
231
+ }
208
232
  if (RESERVED_FILE.test(file) || RESERVED_FILE_OTHER.test(file)) {
209
233
  return {
210
234
  ok: false,
@@ -296,7 +320,7 @@ export function gateComponent(input) {
296
320
  because: `\`${name}\` is a capitalised export in a file with no markup - a constant, a config object or a helper`,
297
321
  };
298
322
  }
299
- if (input.screens != null && input.screens < SCREENS_FOR_SYSTEM) {
323
+ if (input.screens != null && input.screens < SCREENS_FOR_SYSTEM && !livesInSharedFolder(file)) {
300
324
  return {
301
325
  ok: false,
302
326
  why: "feature",
@@ -436,9 +436,20 @@ internal = []) {
436
436
  }
437
437
  }
438
438
  }
439
+ /**
440
+ * A PEÇA DO FRAMEWORK - `StrictMode`, `Suspense`, `Link`, `Form`, `Image`. É encanamento que o React e o Next dão
441
+ * a todo mundo, e não interface de ninguém: nas rodadas 11, 13 e 14 (28/09) `next/link`, `next/form` e o
442
+ * `StrictMode` entraram no sistema como componentes do projeto. Uma biblioteca de INTERFACE (`@mui/material`,
443
+ * `@radix-ui/*`) continua contando - é ela que se cruza com a nossa.
444
+ */
445
+ const FRAMEWORK_PIECE = /^(react|react-dom(\/.*)?|next\/(link|form|image|script|head|dynamic|navigation|router)|react-router(-dom)?|@remix-run\/react|@tanstack\/react-router)$/;
446
+ export function isFrameworkPiece(origin) {
447
+ return Boolean(origin && FRAMEWORK_PIECE.test(origin));
448
+ }
439
449
  /** The inventory, commonest first. */
440
450
  export function tallyToInventory(tally, max = 80) {
441
451
  return [...tally.entries()]
452
+ .filter(([key]) => !isFrameworkPiece(key.slice(key.indexOf("\u0000") + 1) || undefined))
442
453
  .map(([key, v]) => ({
443
454
  /** A chave carrega a origem para separar as linhas - o nome é a primeira metade dela. */
444
455
  name: key.split("\u0000")[0],
@@ -172,13 +172,22 @@ export function declaresName(installedCss, name) {
172
172
  * ENTREGA o valor pedido. Sem valor comparável ele fica aberto e quem decide é a pessoa, no cartão -
173
173
  * um pedido aberto custa uma linha numa tela, um fechamento falso custa o bug.
174
174
  */
175
- export function satisfiedRequests(requests, installedCss) {
175
+ export function satisfiedRequests(requests, installedCss,
176
+ /**
177
+ * AS FOLHAS DELE - quando vêm, o nome tem de estar NELAS (rodada 11, 28/09). O css instalado mora em
178
+ * `_synthesisui/ds/`, que o app dele não importa: `text-display` existia no nosso `theme.css`, o pedido fechou
179
+ * como "delivered", e o hero dele continuou sem o tamanho.
180
+ */
181
+ theirCss) {
176
182
  const declared = declaredValues(installedCss);
183
+ const theirs = theirCss == null ? null : declaredValues(theirCss);
177
184
  return requests.filter((r) => {
178
185
  const name = checkableName(r);
179
186
  if (!name)
180
187
  return false;
181
- const present = candidatesFor(name).filter((c) => declared.has(c.toLowerCase()));
188
+ if (theirs && !theirs.has(`--${name}`.toLowerCase()))
189
+ return false;
190
+ const present = candidatesFor(name).filter((c) => declared.has(c.toLowerCase()) || Boolean(theirs?.has(c.toLowerCase())));
182
191
  if (present.length === 0)
183
192
  return false;
184
193
  if (!r.existed)
@@ -190,6 +199,29 @@ export function satisfiedRequests(requests, installedCss) {
190
199
  return present.some((c) => resolved(declared, declared.get(c.toLowerCase()) ?? "") === asked);
191
200
  });
192
201
  }
202
+ /** As folhas do projeto dele - fora de `_synthesisui/`, `node_modules` e das saídas de build. Sass pela lente. */
203
+ export async function readTheirCss(root) {
204
+ const { readStyleSheet } = await import("./scss-lens.js");
205
+ const SKIP = new Set(["node_modules", "_synthesisui", ".next", "dist", "build", "out", ".git", "coverage"]);
206
+ let css = "";
207
+ let files = 0;
208
+ const walk = async (dir) => {
209
+ for (const e of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
210
+ if (files > 400)
211
+ return;
212
+ if (e.isDirectory()) {
213
+ if (!SKIP.has(e.name) && !e.name.startsWith("."))
214
+ await walk(join(dir, e.name));
215
+ }
216
+ else if (/\.(css|scss|sass)$/i.test(e.name)) {
217
+ files += 1;
218
+ css += `\n${await readStyleSheet(join(dir, e.name))}`;
219
+ }
220
+ }
221
+ };
222
+ await walk(root);
223
+ return css;
224
+ }
193
225
  export async function readInstalledCss(root) {
194
226
  const dsDir = join(root, "_synthesisui", "ds");
195
227
  let css = "";
@@ -231,7 +263,7 @@ export async function verifyAndCloseRequests(root) {
231
263
  const css = await readInstalledCss(root);
232
264
  if (!css)
233
265
  return [];
234
- const satisfied = satisfiedRequests(requests, css);
266
+ const satisfied = satisfiedRequests(requests, css, await readTheirCss(root));
235
267
  for (const r of satisfied)
236
268
  await closeRequest(root, r.id);
237
269
  return satisfied;
@@ -9,6 +9,7 @@
9
9
  * Text in, findings out. No filesystem, no AST, no network - a diagnosis that
10
10
  * takes eight seconds and needs a build step is a diagnosis nobody runs.
11
11
  */
12
+ import { propertyAt, roleFits } from "./their-names.js";
12
13
  import { utilitiesOn } from "./idiom-names.js";
13
14
  import { normalizeValue, tokenMatch } from "./tokens.js";
14
15
  /**
@@ -490,7 +491,8 @@ export function scanSource(file, source, table) {
490
491
  return scanCore(file, source, table);
491
492
  }
492
493
  /** `--color-ocean-500: #059aed;` - uma declaração de custom property no CSS dele. */
493
- const DECLARES = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/i;
494
+ /** Cada declaração `--x: valor;` da linha, quantas houver - ver o laço em `scanCore`. */
495
+ const DECLARES_ALL = /(?:^|[\s{;])(--[a-z0-9-]+)\s*:\s*([^;{}]+);/gi;
494
496
  function scanCore(file, source, table) {
495
497
  const findings = [];
496
498
  const phantoms = [];
@@ -570,8 +572,13 @@ function scanCore(file, source, table) {
570
572
  * Os dois são coletados aqui e cruzados no fim: uma variável pode ser usada num arquivo e
571
573
  * declarada em outro, então a resposta não existe enquanto a varredura não terminar.
572
574
  */
573
- const declaredHere = DECLARES.exec(line);
574
- if (declaredHere && !declaredHere[1].toLowerCase().startsWith("--ds-")) {
575
+ /**
576
+ * TODAS AS DECLARAÇÕES DA LINHA - `:root { --line: #dde5e8; --gap-2: 8px; }` numa linha só contava nenhuma, porque
577
+ * a régua lia um `--x:` por linha e no começo dela (28/09). O jeito de projeto pequeno escrever token.
578
+ */
579
+ for (const declaredHere of line.matchAll(DECLARES_ALL)) {
580
+ if (declaredHere[1].toLowerCase().startsWith("--ds-"))
581
+ continue;
575
582
  const value = declaredHere[2].trim();
576
583
  ownDeclared.push({
577
584
  name: declaredHere[1].toLowerCase(),
@@ -683,7 +690,9 @@ function scanCore(file, source, table) {
683
690
  * A chave carrega o `kind` porque o valor sozinho não decide: `4px` é `--radius-xs` e
684
691
  * `--spacing` no vocabulário dele, e qual dos dois está certo depende de onde o literal está.
685
692
  */
686
- const theirs = table.aliases.get(`${kind}:${normalizeValue(literal, table.rootPx)}`);
693
+ const aliased = table.aliases.get(`${kind}:${normalizeValue(literal, table.rootPx)}`);
694
+ /** O valor igual não basta - o papel também, ver `roleFits` (r13 e r14, 28/09). */
695
+ const theirs = aliased && roleFits(propertyAt(line, col), aliased.name) ? aliased : undefined;
687
696
  /** A procedência do `token`, lida da tabela que o achou - ver `tokenIsTheirs`. */
688
697
  const tableIsTheirs = table.source !== null && table.source !== "installed";
689
698
  findings.push({
@@ -868,7 +877,15 @@ function scanCore(file, source, table) {
868
877
  : null),
869
878
  };
870
879
  }
871
- export function diagnose(files) {
880
+ export function diagnose(files,
881
+ /**
882
+ * AS FOLHAS QUE DECLARAM O VOCABULÁRIO DELE, quando a varredura é de um arquivo só - rodada 13, 28/09.
883
+ *
884
+ * `ownUses` só existia para o nome declarado DENTRO do que foi varrido: o `check_file` de um `.module.css`
885
+ * que escreve `var(--space-1)` respondia "nothing here carries a design value", com o `--space-1` declarado
886
+ * em `src/styles/tokens.css`. O contexto só ensina os NOMES; nada dele entra na conta.
887
+ */
888
+ context = []) {
872
889
  const flat = files.flatMap((f) => f.findings.map((x) => ({ ...x, file: f.file })));
873
890
  const counts = {
874
891
  color: 0,
@@ -892,7 +909,7 @@ export function diagnose(files) {
892
909
  * `var(--algo)` que ninguém declara continua fora da conta, porque ele não pinta nada.
893
910
  */
894
911
  const ownDeclared = new Map();
895
- for (const f of files)
912
+ for (const f of [...files, ...context])
896
913
  for (const d of f.ownDeclared ?? [])
897
914
  if (!ownDeclared.has(d.name))
898
915
  ownDeclared.set(d.name, d.value);
@@ -16,14 +16,112 @@
16
16
  */
17
17
  const DECL = /^\s*\$([A-Za-z_][\w-]*)\s*:\s*([^;]+?)\s*(?:!default|!global)?\s*;/;
18
18
  const USE = /\$([A-Za-z_][\w-]*)/g;
19
- /** As declarações do nível de fora, na ordem, sem seguir aliases. */
19
+ /** As vírgulas do nível de fora - `rgba(14, 20, 40, 0.08)` é um valor só. */
20
+ function splitTop(body) {
21
+ const out = [];
22
+ let depth = 0;
23
+ let quote = null;
24
+ let cur = "";
25
+ for (const ch of body) {
26
+ if (quote) {
27
+ if (ch === quote)
28
+ quote = null;
29
+ }
30
+ else if (ch === '"' || ch === "'")
31
+ quote = ch;
32
+ else if (ch === "(")
33
+ depth += 1;
34
+ else if (ch === ")")
35
+ depth -= 1;
36
+ else if (ch === "," && depth === 0) {
37
+ out.push(cur);
38
+ cur = "";
39
+ continue;
40
+ }
41
+ cur += ch;
42
+ }
43
+ out.push(cur);
44
+ return out.map((p) => p.trim()).filter(Boolean);
45
+ }
46
+ const unquote = (k) => k.trim().replace(/^["']|["']$/g, "");
47
+ /** Os mapas do nível de fora, de uma ou várias linhas, com as posições no texto. */
48
+ function topLevelMaps(scss) {
49
+ const out = [];
50
+ const HEAD = /(^|\n)[ \t]*\$([A-Za-z_][\w-]*)\s*:\s*\(/g;
51
+ let depthAt = 0;
52
+ let scanned = 0;
53
+ for (let m = HEAD.exec(scss); m; m = HEAD.exec(scss)) {
54
+ for (const ch of scss.slice(scanned, m.index)) {
55
+ if (ch === "{")
56
+ depthAt += 1;
57
+ else if (ch === "}")
58
+ depthAt = Math.max(0, depthAt - 1);
59
+ }
60
+ scanned = m.index;
61
+ if (depthAt > 0)
62
+ continue;
63
+ const open = m.index + m[0].length - 1;
64
+ let depth = 0;
65
+ let close = -1;
66
+ for (let i = open; i < scss.length; i += 1) {
67
+ if (scss[i] === "(")
68
+ depth += 1;
69
+ else if (scss[i] === ")") {
70
+ depth -= 1;
71
+ if (depth === 0) {
72
+ close = i;
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ if (close < 0)
78
+ continue;
79
+ const semi = scss.indexOf(";", close);
80
+ if (semi < 0 || scss.slice(close + 1, semi).replace(/!default|!global/g, "").trim())
81
+ continue;
82
+ const entries = [];
83
+ for (const part of splitTop(scss.slice(open + 1, close))) {
84
+ const at = part.search(/:\s/) >= 0 ? part.search(/:\s/) : part.indexOf(":");
85
+ if (at <= 0)
86
+ continue;
87
+ const key = unquote(part.slice(0, at));
88
+ const value = part.slice(at + 1).trim();
89
+ /** um mapa dentro do mapa não é uma decisão de uma linha - fica de fora, como um `@mixin` */
90
+ if (!/^[\w-]+$/.test(key) || value.startsWith("("))
91
+ continue;
92
+ entries.push({ key, value });
93
+ }
94
+ out.push({ name: m[2], entries, from: m.index + m[1].length, to: semi + 1 });
95
+ HEAD.lastIndex = semi + 1;
96
+ }
97
+ return out;
98
+ }
99
+ /** `@function color($name) { @return map-get($semantic, $name); }` -> `color` lê o mapa `semantic`. */
100
+ function accessors(scss) {
101
+ const out = new Map();
102
+ const FN = /@function\s+([A-Za-z_][\w-]*)\s*\(\s*\$([\w-]+)\s*\)\s*\{\s*@return\s+map-get\(\s*\$([\w-]+)\s*,\s*\$\2\s*\)\s*;?\s*\}/g;
103
+ for (let m = FN.exec(scss); m; m = FN.exec(scss))
104
+ out.set(m[1], m[3]);
105
+ return out;
106
+ }
107
+ /** `map-get($m, "k")` e `color("k")` escritos como o `var()` do nome que alcançam. */
108
+ function readMapAccess(text, fns) {
109
+ let out = text.replace(/map-get\(\s*\$([\w-]+)\s*,\s*["']?([\w-]+)["']?\s*\)/g, (_w, map, key) => `var(--${map}-${key})`);
110
+ if (fns.size > 0)
111
+ out = out.replace(/(?<![\w$@-])([A-Za-z_][\w-]*)\(\s*["']?([\w-]+)["']?\s*\)/g, (whole, fn, key) => {
112
+ const map = fns.get(fn);
113
+ return map ? `var(--${map}-${key})` : whole;
114
+ });
115
+ return out;
116
+ }
117
+ /** As declarações do nível de fora, na ordem, sem seguir aliases - os mapas saem por `topLevelMaps`. */
20
118
  function topLevel(scss) {
21
119
  const out = [];
22
120
  let depth = 0;
23
121
  scss.split("\n").forEach((text, line) => {
24
122
  if (depth === 0) {
25
123
  const m = DECL.exec(text);
26
- if (m)
124
+ if (m && !m[2].trim().startsWith("("))
27
125
  out.push({ name: m[1], value: m[2].trim(), line });
28
126
  }
29
127
  for (const ch of text) {
@@ -35,38 +133,83 @@ function topLevel(scss) {
35
133
  });
36
134
  return out;
37
135
  }
38
- /** `$nome` -> o valor, com os aliases dele seguidos (`$accent: $teal-500` -> `#0e9f91`). */
136
+ /** `$nome` -> o valor, com os aliases dele seguidos (`$accent: $teal-500` -> `#0e9f91`). Cada chave de mapa é `$mapa-chave`. */
39
137
  export function scssVariables(scss) {
40
138
  const raw = new Map(topLevel(scss).map((d) => [`$${d.name}`, d.value]));
139
+ for (const map of topLevelMaps(scss))
140
+ for (const { key, value } of map.entries)
141
+ raw.set(`$${map.name}-${key}`, value);
142
+ const fns = accessors(scss);
143
+ const toAlias = (value) => readMapAccess(value, fns).replace(/^var\(--([\w-]+)\)$/, (_w, n) => `$${n}`);
41
144
  const out = new Map();
42
145
  for (const name of raw.keys()) {
43
146
  const seen = new Set([name]);
44
- let value = raw.get(name);
147
+ let value = toAlias(raw.get(name));
45
148
  for (let ref = /^\$([A-Za-z_][\w-]*)$/.exec(value); ref; ref = /^\$([A-Za-z_][\w-]*)$/.exec(value)) {
46
149
  const next = raw.get(`$${ref[1]}`);
47
150
  if (next === undefined || seen.has(`$${ref[1]}`))
48
151
  break;
49
152
  seen.add(`$${ref[1]}`);
50
- value = next;
153
+ value = toAlias(next);
51
154
  }
52
155
  out.set(name, value);
53
156
  }
54
157
  return out;
55
158
  }
159
+ /**
160
+ * COMO O NOME SE ESCREVE NO SASS DELE - o caminho de volta do mapa (`scss-speech.ts`).
161
+ *
162
+ * `--semantic-brand` é `color("brand")` quando existe a função de acesso, e `map-get($semantic, "brand")`
163
+ * quando não; uma `$variável` simples é ela mesma.
164
+ */
165
+ export function scssSpeech(scss) {
166
+ const out = new Map();
167
+ for (const d of topLevel(scss))
168
+ out.set(d.name, `$${d.name}`);
169
+ const byMap = new Map();
170
+ for (const [fn, map] of accessors(scss))
171
+ if (!byMap.has(map))
172
+ byMap.set(map, fn);
173
+ for (const map of topLevelMaps(scss))
174
+ for (const { key } of map.entries) {
175
+ const fn = byMap.get(map.name);
176
+ const k = /^\d+$/.test(key) ? key : `"${key}"`;
177
+ out.set(`${map.name}-${key}`, fn ? `${fn}(${k})` : `map-get($${map.name}, ${k})`);
178
+ }
179
+ return out;
180
+ }
56
181
  /** A folha como o leitor de CSS a entende - ver o cabeçalho. Sem `$`, ela volta byte a byte. */
57
- export function scssAsCustomProperties(scss) {
58
- if (!scss.includes("$"))
182
+ export function scssAsCustomProperties(scss, context = "") {
183
+ if (!scss.includes("$") && !context && !/(^|[ \t])\/\//.test(scss))
59
184
  return scss;
60
- const decls = new Map(topLevel(scss).map((d) => [d.line, d]));
61
- return scss
185
+ const fns = new Map([...accessors(context), ...accessors(scss)]);
186
+ /**
187
+ * O `//` DO SASS SAI - para o leitor de CSS ele não é comentário, e `// Beacon - design tokens` na linha
188
+ * de cima virava parte do seletor do bloco seguinte, que era descartado inteiro (o `$palette` do r14, 28/09).
189
+ * Só fora de string e com espaço ou começo de linha antes: `url(https://...)` fica.
190
+ */
191
+ let text = scss.replace(/(^|[ \t])\/\/[^\n]*/g, (whole, lead, at) => {
192
+ const before = scss.slice(scss.lastIndexOf("\n", at) + 1, at);
193
+ return (before.match(/["']/g)?.length ?? 0) % 2 === 1 ? whole : lead;
194
+ });
195
+ for (const map of topLevelMaps(text).reverse()) {
196
+ const block = map.entries
197
+ .map(({ key, value }) => `--${map.name}-${key}: ${readMapAccess(value, fns).replace(USE, "var(--$1)")};`)
198
+ .join(" ");
199
+ text = `${text.slice(0, map.from)}:root { ${block} }${text.slice(map.to)}`;
200
+ }
201
+ const decls = new Map(topLevel(text).map((d) => [d.line, d]));
202
+ return text
62
203
  .split("\n")
63
- .map((text, line) => {
64
- const d = decls.get(line);
204
+ .map((line, i) => {
205
+ const d = decls.get(i);
65
206
  if (d)
66
- return `:root { --${d.name}: ${d.value.replace(USE, "var(--$1)")}; }`;
67
- if (/^\s*@(use|forward|import)\b/.test(text))
68
- return text;
69
- return text.replace(USE, "var(--$1)");
207
+ return `:root { --${d.name}: ${readMapAccess(d.value, fns).replace(USE, "var(--$1)")}; }`;
208
+ if (/^\s*@(use|forward|import)\b/.test(line))
209
+ return line;
210
+ if (/^\s*:root \{ --/.test(line))
211
+ return line;
212
+ return readMapAccess(line, fns).replace(USE, "var(--$1)");
70
213
  })
71
214
  .join("\n");
72
215
  }
@@ -78,5 +221,32 @@ export function scssAsCustomProperties(scss) {
78
221
  export async function readStyleSheet(file) {
79
222
  const { readFile } = await import("node:fs/promises");
80
223
  const text = await readFile(file, "utf8").catch(() => "");
81
- return /\.(scss|sass)$/i.test(file) ? scssAsCustomProperties(text) : text;
224
+ if (!/\.(scss|sass)$/i.test(file))
225
+ return text;
226
+ return scssAsCustomProperties(text, await usedSheets(file, text));
227
+ }
228
+ /**
229
+ * AS FOLHAS QUE ELA `@use`/`@import` - é lá que moram as funções de acesso (`color()`, `space()`). Sem elas,
230
+ * `background: color("brand")` no `globals.scss` não seria lido como o `--semantic-brand` que é.
231
+ */
232
+ async function usedSheets(file, text) {
233
+ const { readFile } = await import("node:fs/promises");
234
+ const { dirname, join } = await import("node:path");
235
+ const out = [];
236
+ for (const m of text.matchAll(/^\s*@(?:use|import|forward)\s+["']([^"']+)["']/gm)) {
237
+ const spec = m[1];
238
+ if (/^(sass:|https?:)/.test(spec))
239
+ continue;
240
+ const base = join(dirname(file), spec);
241
+ const dir = dirname(base);
242
+ const name = base.slice(dir.length + 1).replace(/\.(scss|sass)$/i, "");
243
+ for (const candidate of [`${name}.scss`, `_${name}.scss`, `${name}.sass`, `_${name}.sass`]) {
244
+ const got = await readFile(join(dir, candidate), "utf8").catch(() => null);
245
+ if (got != null) {
246
+ out.push(got);
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ return out.join("\n");
82
252
  }
@@ -258,3 +258,34 @@ export function theirNames(ours, theirs) {
258
258
  export function withTheirNames(ours, theirs) {
259
259
  return { ...ours, aliases: theirNames(ours, theirs) };
260
260
  }
261
+ /**
262
+ * O PAPEL DO NOME CABE NA PROPRIEDADE? - rodadas 13 e 14 do refinamento, 28/09.
263
+ *
264
+ * O valor igual não basta: `#fff` no texto do botão de perigo não é `--color-surface` (no escuro ela vira o fundo
265
+ * escuro e o texto some), e `1rem` num `line-height` não é o tamanho de fonte `md`. A régua lê só o que o nome DIZ -
266
+ * uma palavra de fundo, de texto ou de tamanho de fonte -, e um nome que não diz nada disso continua valendo.
267
+ */
268
+ const BACKGROUND_WORD = /(^|[-_$])(bg|background|surface|canvas|page|paper|base)([-_]|$)/i;
269
+ const TEXT_WORD = /(^|[-_$])(text|ink|fg|foreground)([-_]|$)|(^|[-_$])on-/i;
270
+ const FONT_SIZE_WORD = /(^|[-_$])(text-size|font-size|type-scale|fontsize)([-_(]|$)/i;
271
+ export function roleFits(property, name) {
272
+ if (!property)
273
+ return true;
274
+ const p = property.trim().toLowerCase();
275
+ const n = name.replace(/^--/, "").replace(/^(colou?r[-_])/i, "");
276
+ if (p === "color" || p === "fill" || p === "stroke" || p === "caret-color")
277
+ return !BACKGROUND_WORD.test(n) || TEXT_WORD.test(n);
278
+ if (p === "background" || p === "background-color")
279
+ return !TEXT_WORD.test(n) || BACKGROUND_WORD.test(n);
280
+ if (FONT_SIZE_WORD.test(name))
281
+ return p === "font-size" || p === "font";
282
+ return true;
283
+ }
284
+ /** A propriedade que governa a posição `col` de uma linha de CSS ou de um objeto `style` - `null` quando não há. */
285
+ export function propertyAt(line, col) {
286
+ const before = col >= 0 ? line.slice(0, col) : line;
287
+ const m = /([A-Za-z-]+)["']?\s*:\s*[^:;{}]*$/.exec(before);
288
+ if (!m)
289
+ return null;
290
+ return m[1].replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
291
+ }
package/dist/fonts.js CHANGED
@@ -54,7 +54,11 @@ export function customFontFamilies(families) {
54
54
  const names = [];
55
55
  for (const slot of familySlots(families)) {
56
56
  const name = firstFamily(families[slot] ?? "");
57
- if (!name)
57
+ /**
58
+ * UMA VARIÁVEL NÃO É FAMÍLIA - `var(--font-geist-sans)` é o next/font dele já carregando a fonte, e saía como
59
+ * `family=var(--font-geist-sans)` na URL do Google Fonts (rodada 12, 28/09).
60
+ */
61
+ if (!name || /^var\(|^--/.test(name.trim()))
58
62
  continue;
59
63
  const key = name.toLowerCase();
60
64
  if (GENERIC_FAMILIES.has(key) || seen.has(key))
@@ -289,7 +289,13 @@
289
289
  * Na mesma versão, o componente sai na FORMA do projeto dele: `.module.(s)css` com `styles.root` onde ele usa
290
290
  * CSS Modules, a classe sem o nosso `ds-` onde ele usa folha global (a linha do dono, 28/09 00:49).
291
291
  */
292
- export const MATERIALISER_SINCE = "0.16.469";
292
+ /**
293
+ * 0.16.469 -> 0.16.471 em 28/09, e o passo 1 dá **SIM**: num projeto Sass com BEM aninhado (`&--x`, `&__x`) o
294
+ * componente sai em BEM, e não em classes do Tailwind que ali não pintam nada; e a chave de um mapa do Sass volta
295
+ * escrita como a função dele (`color("brand")`), nunca `$semantic-brand` (rodada 14 do refinamento). Um
296
+ * componente escrito antes num projeto desses está sem estilo.
297
+ */
298
+ export const MATERIALISER_SINCE = "0.16.471";
293
299
  /**
294
300
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
295
301
  *
@@ -456,7 +462,12 @@ export const COUNTED_DIFFERENTLY = "this run counts a value as named only when Y
456
462
  * arquivos do turno, e não só os três que relata - rodada 5: o leigo-5 escreveu sete e o painel recebeu
457
463
  * três. Um hook anterior segue mandando ao painel um turno pela metade.
458
464
  */
459
- export const CHECKER_SINCE = "0.16.467";
465
+ /**
466
+ * 0.16.467 -> 0.16.471 em 28/09, e o passo 1 dá **SIM**: a lente do Sass que o fim do turno usa lê os mapas
467
+ * (`$palette`, `color("brand")`) - rodada 14 do refinamento. Um hook anterior acusa como valor à mão toda cor
468
+ * que o projeto guarda num mapa.
469
+ */
470
+ export const CHECKER_SINCE = "0.16.471";
460
471
  /**
461
472
  * A ÚLTIMA VERSÃO EM QUE OS LEITORES PASSARAM A PRODUZIR UM CENSO DIFERENTE.
462
473
  *
@@ -915,7 +926,13 @@ export const CHECKER_SINCE = "0.16.467";
915
926
  * em TypeScript como o vocabulário dele, e a classe escolhida em tempo de execução pela união que tipa a prop
916
927
  * (rodadas 7 a 10 do refinamento). Um censo lido antes guardou `declared: {}` para esses projetos.
917
928
  */
918
- export const READER_SINCE = "0.16.469";
929
+ /**
930
+ * 0.16.469 -> 0.16.471 em 28/09, e o passo 1 dá **SIM** (rodadas 11 a 14 do refinamento): o censo lê os mapas do
931
+ * Sass (0 -> 18 cores no r14), o BEM aninhado como BEM, tira da organização o arquivo de rota (`page`, `layout`), e
932
+ * não põe no sistema a peça do framework (`StrictMode`, `next/link`) nem a raiz do Vite (`src/App.tsx`); e o que
933
+ * mora na pasta compartilhada de componentes nunca vira "feature". Um censo lido antes guardou tudo isso errado.
934
+ */
935
+ export const READER_SINCE = "0.16.471";
919
936
  /**
920
937
  * O QUE ESTÁ INSTALADO AQUI FICOU PARA TRÁS - e as DUAS condições que fazem isso ser verdade.
921
938
  *
@@ -1,6 +1,7 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join, relative } from "node:path";
3
- import { scssVariables } from "./doctor/scss-lens.js";
3
+ import { scssSpeech, scssVariables } from "./doctor/scss-lens.js";
4
+ import { roleFits } from "./doctor/their-names.js";
4
5
  const SKIP = new Set(["node_modules", ".next", "dist", "build", "out", "_synthesisui", "coverage"]);
5
6
  /** O vocabulário SCSS do projeto: a folha que declara mais `$variáveis` no nível de fora. */
6
7
  export async function scssVocabularyOf(root) {
@@ -13,9 +14,10 @@ export async function scssVocabularyOf(root) {
13
14
  if (entry.isDirectory())
14
15
  await walk(path);
15
16
  else if (/\.(scss|sass)$/i.test(entry.name) && !/\.module\./i.test(entry.name)) {
16
- const vars = scssVariables(await readFile(path, "utf8").catch(() => ""));
17
+ const text = await readFile(path, "utf8").catch(() => "");
18
+ const vars = scssVariables(text);
17
19
  if (vars.size > (best?.vars.size ?? 0))
18
- best = { vars, tokensFile: relative(root, path).split("\\").join("/") };
20
+ best = { vars, speak: scssSpeech(text), tokensFile: relative(root, path).split("\\").join("/") };
19
21
  }
20
22
  }
21
23
  };
@@ -31,10 +33,12 @@ function usePath(tokensFile, compDir) {
31
33
  export function inScssSpeech(files, vocab, compDir) {
32
34
  if (!vocab || vocab.vars.size === 0)
33
35
  return files;
36
+ /** Como cada nome se escreve - `$nome`, ou a chamada que alcança a chave do mapa. */
37
+ const said = (name) => vocab.speak?.get(name.replace(/^\$/, "")) ?? name;
34
38
  const byValue = new Map();
35
39
  for (const [name, value] of vocab.vars) {
36
40
  const k = value.trim().toLowerCase();
37
- byValue.set(k, [...(byValue.get(k) ?? []), name]);
41
+ byValue.set(k, [...(byValue.get(k) ?? []), said(name)]);
38
42
  }
39
43
  const renamed = new Map();
40
44
  const out = files.map((f) => {
@@ -43,10 +47,11 @@ export function inScssSpeech(files, vocab, compDir) {
43
47
  const filename = f.filename.replace(/\.css$/, ".scss");
44
48
  renamed.set(f.filename, filename);
45
49
  const body = f.content
46
- .replace(/var\(\s*--([A-Za-z_][\w-]*)\s*\)/g, (whole, n) => vocab.vars.has(`$${n}`) ? `$${n}` : whole)
47
- .replace(/:\s*([^;{}]+?)\s*;/g, (whole, value) => {
48
- const hits = byValue.get(value.trim().toLowerCase());
49
- return hits && hits.length === 1 ? `: ${hits[0]};` : whole;
50
+ .replace(/var\(\s*--([A-Za-z_][\w-]*)\s*\)/g, (whole, n) => vocab.vars.has(`$${n}`) ? said(`$${n}`) : whole)
51
+ .replace(/([\w-]+)(\s*):\s*([^;{}]+?)\s*;/g, (whole, prop, gap, value) => {
52
+ /** O valor igual e o papel também - `line-height: 1rem` não é o tamanho de fonte `md` (r14, 28/09). */
53
+ const hits = byValue.get(value.trim().toLowerCase())?.filter((h) => roleFits(prop, h));
54
+ return hits && hits.length === 1 ? `${prop}${gap}: ${hits[0]};` : whole;
50
55
  });
51
56
  return { ...f, filename, content: `@use "${usePath(vocab.tokensFile, compDir)}" as *;\n\n${body}` };
52
57
  });
@@ -0,0 +1,58 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { loadSystem, walkAll } from "./commands/doctor.js";
4
+ import { diagnose, scanSource } from "./doctor/scan.js";
5
+ export async function whatGrew(root, census,
6
+ /** As leituras do último envio (`SyncMark.readings`) - `null` quando nunca houve um, e aí nada "nasceu". */
7
+ before) {
8
+ if (!before)
9
+ return { born: [], gone: [] };
10
+ const now = (census.components ?? []).filter((c) => typeof c.name === "string");
11
+ const names = new Set(now.map((c) => c.name));
12
+ const gone = Object.keys(before).filter((n) => !names.has(n));
13
+ const fresh = now.filter((c) => !(c.name in before) && typeof c.api?.file === "string");
14
+ if (fresh.length === 0)
15
+ return { born: [], gone };
16
+ const { table } = await loadSystem(root).catch(() => ({ table: null }));
17
+ const context = [];
18
+ if (table) {
19
+ for await (const file of walkAll([root])) {
20
+ const rel = relative(root, file);
21
+ if (!/\.(css|scss|sass|less)$/i.test(rel))
22
+ continue;
23
+ const src = await readFile(file, "utf8").catch(() => "");
24
+ const report = src ? scanSource(rel, src, table) : null;
25
+ if (report?.ownDeclared?.length)
26
+ context.push(report);
27
+ }
28
+ }
29
+ const born = [];
30
+ for (const c of fresh) {
31
+ const file = c.api?.file;
32
+ const src = table ? await readFile(join(root, file), "utf8").catch(() => null) : null;
33
+ if (src == null || !table) {
34
+ born.push({ name: c.name, file, fromSystem: 0, of: 0, byHand: [] });
35
+ continue;
36
+ }
37
+ const d = diagnose([scanSource(file, src, table)], context);
38
+ born.push({
39
+ name: c.name,
40
+ file,
41
+ fromSystem: d.reach.measured ? d.reach.uses : 0,
42
+ of: d.reach.measured ? d.reach.of : 0,
43
+ byHand: [...new Set(d.findings.map((f) => f.literal))],
44
+ });
45
+ }
46
+ return { born, gone };
47
+ }
48
+ /** As linhas do bloco, ou nada. Uma por componente: o nome, o arquivo, a medida e o que ficou à mão. */
49
+ export function describeGrowth(grown) {
50
+ const out = [];
51
+ for (const b of grown.born)
52
+ out.push(b.of === 0
53
+ ? `${b.name} (${b.file}) - no colour or spacing of its own to measure`
54
+ : `${b.name} (${b.file}) - ${b.fromSystem} of ${b.of} values from your system${b.byHand.length > 0 ? `; by hand: ${b.byHand.slice(0, 4).join(", ")}${b.byHand.length > 4 ? ", …" : ""}` : ""}`);
55
+ for (const g of grown.gone)
56
+ out.push(`${g} is no longer in your code - it leaves the draft with this send`);
57
+ return out;
58
+ }
package/dist/written.js CHANGED
@@ -43,7 +43,12 @@ export async function recordWritten(slugDir, entry, files) {
43
43
  * do mesmo jeito. Devolve vazio quando nada divergiu (nosso para regenerar) e `null` quando não
44
44
  * há fingerprint gravado (install legado - o chamador mantém o comportamento de sempre).
45
45
  */
46
- export async function editedSinceWritten(recorded, dir) {
46
+ export async function editedSinceWritten(recorded, dir,
47
+ /**
48
+ * O ARQUIVO QUE SUMIU CONTA COMO EDIÇÃO? Sim por padrão - no `upgrade` ninguém pediu o arquivo de volta. Não no
49
+ * `component`, que é ele PEDINDO o componente: lá "kept YOUR file(s)" sobre um arquivo apagado era falso (r14, 28/09).
50
+ */
51
+ opts = {}) {
47
52
  if (!recorded)
48
53
  return null;
49
54
  const edited = [];
@@ -54,7 +59,8 @@ export async function editedSinceWritten(recorded, dir) {
54
59
  edited.push(filename);
55
60
  }
56
61
  catch {
57
- edited.push(filename);
62
+ if (opts.missingIsEdit !== false)
63
+ edited.push(filename);
58
64
  }
59
65
  }
60
66
  return edited;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.469",
3
+ "version": "0.16.471",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {