synthesisui 0.16.63 → 0.16.65

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,10 +1,12 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
3
  import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
4
+ import { findBrokenRefs } from "../doctor/broken-refs.js";
4
5
  import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
5
6
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
6
7
  import { crosswalk, observedRules } from "../doctor/crosswalk.js";
7
8
  import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
9
+ import { describeConvention, detectConventions, IDIOM_LABEL, } from "../doctor/idiom.js";
8
10
  import { diagnose, scanSource } from "../doctor/scan.js";
9
11
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
10
12
  import { buildTable } from "../doctor/tokens.js";
@@ -273,11 +275,15 @@ export async function takeCensus(root) {
273
275
  const tally = emptyTally();
274
276
  const internal = await internalSpecifiers(root);
275
277
  const defined = [];
278
+ // Kept for the reference check below, which needs every file at once: a name
279
+ // is only broken if NOTHING declares it, anywhere.
280
+ const sources = [];
276
281
  for await (const file of walk(root)) {
277
282
  const src = await readFile(file, "utf8").catch(() => "");
278
283
  if (!src)
279
284
  continue;
280
285
  const rel = relative(root, file);
286
+ sources.push({ file: rel, source: src });
281
287
  reports.push(scanSource(rel, src, table));
282
288
  // Stories and tests compose components to SHOW them; counting those as the
283
289
  // product's own composition is the same lie the colour census told before
@@ -368,10 +374,21 @@ export async function takeCensus(root) {
368
374
  // an unreadable package.json costs the name, not the run
369
375
  }
370
376
  }
377
+ // Every name their CSS defines, from the same harvest the token table came
378
+ // from - so a reference is judged against what actually exists, not against
379
+ // the subset we managed to read as a ramp.
380
+ const declaredNames = new Set();
381
+ for (const m of css.matchAll(/(--[a-zA-Z0-9_-]+)\s*:/g)) {
382
+ declaredNames.add(m[1]);
383
+ }
384
+ const brokenRefs = findBrokenRefs(sources, declaredNames);
385
+ const conventions = detectConventions(sources);
371
386
  return {
372
387
  census: 1,
373
388
  project: { name, stack: await detectStack(root) },
374
389
  declared: Object.fromEntries(table.byName),
390
+ ...(brokenRefs.length > 0 ? { brokenRefs } : {}),
391
+ ...(conventions.length > 0 ? { conventions } : {}),
375
392
  ...(schemes.alt.size > 0
376
393
  ? { declaredAlt: Object.fromEntries(schemes.alt) }
377
394
  : {}),
@@ -435,6 +452,55 @@ function summarize(c) {
435
452
  console.log(body(`${paint.strong(v.value)} ${paint.faint(`${v.kind}, ${v.count}× in ${v.files} file${v.files === 1 ? "" : "s"}`)}`));
436
453
  }
437
454
  }
455
+ sayConventions(c);
456
+ sayBrokenRefs(c);
457
+ }
458
+ /**
459
+ * THEIR VOCABULARY, MEASURED. This section exists so every later finding can be
460
+ * phrased as "you already do X, and here you did not" instead of "adopt ours".
461
+ */
462
+ function sayConventions(c) {
463
+ const list = c.conventions ?? [];
464
+ if (list.length === 0)
465
+ return;
466
+ console.log("");
467
+ console.log(section("How you reference a value"));
468
+ for (const conv of list) {
469
+ console.log(body(describeConvention(conv)));
470
+ const off = conv.offIdiom.filter((o) => o.count > 0).slice(0, 3);
471
+ if (off.length > 0) {
472
+ console.log(body(paint.faint(` and ${off.map((o) => `${o.count} ${IDIOM_LABEL[o.idiom]}`).join(", ")}`)));
473
+ }
474
+ }
475
+ console.log("");
476
+ console.log(body(paint.faint("Nothing here is a rule we brought. It is what your own files do most, so the rest can be named against it.")));
477
+ }
478
+ /**
479
+ * NOT DRIFT - BROKEN. A `var()` no declaration answers renders nothing at all,
480
+ * so this goes above everything else the census has to say. It is also the one
481
+ * finding that needs no agreement with us: the convention being broken is
482
+ * theirs.
483
+ */
484
+ function sayBrokenRefs(c) {
485
+ const broken = c.brokenRefs ?? [];
486
+ if (broken.length === 0)
487
+ return;
488
+ const uses = broken.reduce((n, b) => n + b.count, 0);
489
+ console.log("");
490
+ console.log(section("These resolve to nothing"));
491
+ console.log(body(`${uses} reference${uses === 1 ? "" : "s"} to ${broken.length} token${broken.length === 1 ? "" : "s"} your CSS never declares. A ${paint.strong("var()")} with no declaration paints no colour - not a different colour.`));
492
+ console.log("");
493
+ for (const b of broken.slice(0, 8)) {
494
+ const where = `${b.count}× in ${b.files} file${b.files === 1 ? "" : "s"}`;
495
+ console.log(body(b.meant
496
+ ? `${paint.strong(`var(${b.name})`)} ${paint.faint(where)} - you declare ${paint.strong(b.meant)}`
497
+ : `${paint.strong(`var(${b.name})`)} ${paint.faint(`${where}, and nothing close is declared`)}`));
498
+ }
499
+ if (broken.length > 8) {
500
+ console.log(body(paint.faint(`(${broken.length - 8} more)`)));
501
+ }
502
+ console.log("");
503
+ console.log(body(paint.faint("Yours to fix in your own words - nothing here asks you to adopt ours.")));
438
504
  }
439
505
  /**
440
506
  * The contract an agent needs, printed where an agent will read it.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * TOKENS REFERENCED AND NEVER DECLARED.
3
+ *
4
+ * Not drift, not taste - a variable that resolves to nothing. `var(--ocean-50)`
5
+ * in a project whose token is `--color-ocean-50` renders no colour at all, and
6
+ * the component ships with no background.
7
+ *
8
+ * Found by accident (dono, 01/08) while sizing something else: seven of nine
9
+ * references in one real `ImageUploader` were missing the `color-` prefix, and
10
+ * it is live. Nobody noticed because a missing background looks like a design
11
+ * choice.
12
+ *
13
+ * This is the highest-value thing a first read can say, and it costs nothing to
14
+ * find: it is arithmetic over two sets. It also needs no adoption of anything of
15
+ * ours - the convention being violated is THEIRS. A person can act on it in
16
+ * their own vocabulary, in the next commit, without agreeing to a single idea
17
+ * this product has (dono, 01/08: "a normalização será de acordo com cada
18
+ * projeto").
19
+ */
20
+ /** `var(--x)`, including inside a Tailwind arbitrary value: `bg-[var(--x)]`. */
21
+ const VAR_REF = /var\(\s*(--[a-zA-Z0-9_-]+)/g;
22
+ /**
23
+ * PREFIXES A DECLARATION MAY CARRY that a reference dropped.
24
+ *
25
+ * Tailwind v4's `@theme` is the reason this happens at all: declaring
26
+ * `--color-ocean-50` publishes the variable under that full name AND the
27
+ * utility `bg-ocean-50`, so a person who reads their own utilities and then
28
+ * reaches for `var()` writes the utility's name and gets nothing.
29
+ */
30
+ const NAMESPACES = [
31
+ "color",
32
+ "spacing",
33
+ "radius",
34
+ "font",
35
+ "text",
36
+ "shadow",
37
+ "ease",
38
+ "animate",
39
+ ];
40
+ /**
41
+ * Every reference no declaration answers, commonest first.
42
+ *
43
+ * `declared` is the set of names their own CSS defines, verbatim. Anything the
44
+ * scan could not attribute is left out rather than guessed at.
45
+ */
46
+ export function findBrokenRefs(sources, declared) {
47
+ const seen = new Map();
48
+ for (const { file, source } of sources) {
49
+ for (const m of source.matchAll(VAR_REF)) {
50
+ const name = m[1];
51
+ if (declared.has(name))
52
+ continue;
53
+ const hit = seen.get(name) ?? { count: 0, files: new Set() };
54
+ hit.count += 1;
55
+ hit.files.add(file);
56
+ seen.set(name, hit);
57
+ }
58
+ }
59
+ const out = [];
60
+ for (const [name, hit] of seen) {
61
+ const bare = name.slice(2);
62
+ const meant = NAMESPACES.map((ns) => `--${ns}-${bare}`).find((c) => declared.has(c));
63
+ out.push({
64
+ name,
65
+ count: hit.count,
66
+ files: hit.files.size,
67
+ ...(meant ? { meant } : {}),
68
+ });
69
+ }
70
+ // A broken reference somebody typed nine times is worse than one typed once,
71
+ // and one we can name a target for is actionable before one we cannot.
72
+ return out.sort((a, b) => Number(b.meant != null) - Number(a.meant != null) ||
73
+ b.count - a.count ||
74
+ a.name.localeCompare(b.name));
75
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * HOW THIS PROJECT REFERENCES A VALUE - and it is not one answer per project.
3
+ *
4
+ * Measured on a real monorepo (dono, 01/08), scoping by project looked like
5
+ * divergence and was not: `packages/ui` read 52% `var()` and 39% utility, which
6
+ * is one directory holding `.tsx` and `.scss` in the same bucket. Split by
7
+ * LANGUAGE the picture is unambiguous:
8
+ *
9
+ * .tsx 80% tailwind utility
10
+ * .scss 66% css var()
11
+ * .css 67% css var()
12
+ *
13
+ * So the convention belongs to the language, the project is already chosen by
14
+ * `--scope`, and neither needs a person to pick it - it is measured. What a
15
+ * person gets instead is the useful half: what does NOT follow the idiom their
16
+ * own files overwhelmingly use.
17
+ *
18
+ * WHY THIS MATTERS MORE THAN OUR TOKENS (dono, 01/08). A report that says "use
19
+ * `{color.ocean.500}`" asks somebody to restructure a repo to suit us - in a
20
+ * shadcn project that is a migration with real incompatibility risk and a commit
21
+ * nobody wants to review. A report that says "you write `bg-ocean-500` in 80% of
22
+ * your JSX and here you wrote a hex" asks for nothing. Same decision, their
23
+ * vocabulary, a commit the size of the problem.
24
+ *
25
+ * It also explained the worst bug this census has found. `bg-[var(--ocean-50)]`
26
+ * is not merely a dropped `color-` prefix: it is the CSS idiom used inside the
27
+ * JSX idiom - a change of language mid-sentence - and the `.scss` beside it
28
+ * spells the same token correctly, because there that language is the right one.
29
+ */
30
+ /** How each idiom reads, for the report. */
31
+ export const IDIOM_LABEL = {
32
+ "tailwind-utility": "Tailwind utility",
33
+ "css-var": "var(--token)",
34
+ "raw-hex": "raw hex",
35
+ "tailwind-arbitrary-var": "var() inside a utility",
36
+ "shadcn-hsl": "hsl(var(--token))",
37
+ };
38
+ const PATTERNS = [
39
+ // Order matters where two could match the same text: the shadcn wrapper and
40
+ // the arbitrary-value form are both `var()` and both more specific than it.
41
+ ["shadcn-hsl", /hsl\(\s*var\(\s*--/g],
42
+ [
43
+ "tailwind-arbitrary-var",
44
+ /\b(?:bg|text|border|ring|fill|stroke|shadow|from|to|via)-\[var\(\s*--/g,
45
+ ],
46
+ [
47
+ "tailwind-utility",
48
+ /\b(?:bg|text|border|ring|fill|stroke|from|to|via)-(?:[a-z]+-)+\d{2,3}\b/g,
49
+ ],
50
+ ["css-var", /var\(\s*--/g],
51
+ ["raw-hex", /#[0-9a-fA-F]{6}\b/g],
52
+ ];
53
+ /**
54
+ * Extensions worth reporting separately, because they are separate languages
55
+ * with separate correct answers. Anything else is counted and not split out.
56
+ */
57
+ const LANGUAGES = new Set([".tsx", ".jsx", ".scss", ".css", ".less", ".sass"]);
58
+ /**
59
+ * A custom-property declaration, whose right-hand side is a DEFINITION.
60
+ * Deliberately not anchored to a line start: these are written inside `:root`,
61
+ * `@theme` and nested blocks at every indentation.
62
+ */
63
+ const DECLARATION = /--[a-zA-Z0-9_-]+\s*:[^;}]*/g;
64
+ /**
65
+ * Count references per language and name the dominant idiom.
66
+ *
67
+ * A language with too few references to be conclusive is left out rather than
68
+ * given a convention on the strength of three sightings: claiming a project's
69
+ * convention from noise is worse than saying nothing, because every later
70
+ * finding inherits the claim.
71
+ */
72
+ export function detectConventions(sources, minReferences = 20) {
73
+ const byLanguage = new Map();
74
+ for (const { file, source } of sources) {
75
+ const dot = file.lastIndexOf(".");
76
+ const ext = dot === -1 ? "" : file.slice(dot).toLowerCase();
77
+ if (!LANGUAGES.has(ext))
78
+ continue;
79
+ const counts = byLanguage.get(ext) ?? new Map();
80
+ // A DECLARATION IS NOT A REFERENCE. `--color-ocean-500: #059aed` is where a
81
+ // hex belongs, and counting it made a design system's own token file read as
82
+ // 87% raw hex - the worst drift in the repo, when it is the opposite: the one
83
+ // place every other value should be pointing at (dono, 01/08).
84
+ let rest = source.replace(DECLARATION, "");
85
+ // A more specific pattern claims its matches first, so the same text is
86
+ // never counted twice - `bg-[var(--x)]` is one arbitrary-value reference,
87
+ // not also a `var()` one.
88
+ for (const [idiom, re] of PATTERNS) {
89
+ const found = rest.match(re);
90
+ if (!found)
91
+ continue;
92
+ counts.set(idiom, (counts.get(idiom) ?? 0) + found.length);
93
+ rest = rest.replace(re, "");
94
+ }
95
+ byLanguage.set(ext, counts);
96
+ }
97
+ const out = [];
98
+ for (const [language, counts] of byLanguage) {
99
+ const total = [...counts.values()].reduce((n, v) => n + v, 0);
100
+ if (total < minReferences)
101
+ continue;
102
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
103
+ const [idiom, top] = ranked[0];
104
+ out.push({
105
+ language,
106
+ idiom,
107
+ share: top / total,
108
+ total,
109
+ offIdiom: ranked.slice(1).map(([i, count]) => ({ idiom: i, count })),
110
+ });
111
+ }
112
+ return out.sort((a, b) => b.total - a.total);
113
+ }
114
+ /**
115
+ * One line per language, in the report's voice and in THEIR vocabulary.
116
+ *
117
+ * A raw hex winning is NOT a convention. It is the absence of one, and saying
118
+ * "this language reaches for raw hex" dignifies it into a house style somebody
119
+ * might defend - when what the number means is that nothing there points at
120
+ * anything (dono, 01/08).
121
+ */
122
+ export function describeConvention(c) {
123
+ const pct = Math.round(c.share * 100);
124
+ if (c.idiom === "raw-hex") {
125
+ return `${c.language} has no convention - ${pct}% of its ${c.total} references are values typed in place`;
126
+ }
127
+ return `${c.language} reaches for ${IDIOM_LABEL[c.idiom]} - ${pct}% of the ${c.total} references in it`;
128
+ }
129
+ /** True when this language's dominant answer is "no answer". */
130
+ export function hasConvention(c) {
131
+ return c.idiom !== "raw-hex";
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.63",
3
+ "version": "0.16.65",
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": {