synthesisui 0.16.44 → 0.16.45

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.
@@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { basename, join, relative } from "node:path";
3
3
  import { readToken, resolveRegistry } from "../config.js";
4
4
  import { isNearDuplicate } from "../doctor/color-distance.js";
5
+ import { emptyTally, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
5
6
  import { diagnose, scanSource } from "../doctor/scan.js";
6
7
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
7
8
  import { buildTable } from "../doctor/tokens.js";
@@ -124,6 +125,57 @@ async function resolveDeps(root) {
124
125
  }
125
126
  return deps;
126
127
  }
128
+ /**
129
+ * Specifier prefixes this workspace owns: the aliases its tsconfig declares and
130
+ * the names of its sibling packages. Everything else that is not relative comes
131
+ * from node_modules.
132
+ */
133
+ async function internalSpecifiers(root) {
134
+ const out = new Set();
135
+ for (let up = 0, dir = root; up < 4; up++) {
136
+ for (const f of ["tsconfig.json", "tsconfig.base.json"]) {
137
+ const raw = await readFile(join(dir, f), "utf8").catch(() => null);
138
+ if (!raw)
139
+ continue;
140
+ try {
141
+ // tsconfig allows comments and trailing commas; a census must not die
142
+ // on either, and the keys are all we want.
143
+ for (const m of raw.matchAll(/"([^"]+)\/?\*?"\s*:\s*\[/g)) {
144
+ const key = m[1].replace(/\/\*$/, "").replace(/\*$/, "");
145
+ if (key && !key.startsWith("."))
146
+ out.add(key.replace(/\/$/, ""));
147
+ }
148
+ }
149
+ catch {
150
+ // unreadable config costs the aliases, not the run
151
+ }
152
+ }
153
+ for (const group of ["packages", "libs", "apps"]) {
154
+ for (const e of await readdir(join(dir, group), {
155
+ withFileTypes: true,
156
+ }).catch(() => [])) {
157
+ if (!e.isDirectory())
158
+ continue;
159
+ const raw = await readFile(join(dir, group, e.name, "package.json"), "utf8").catch(() => null);
160
+ if (!raw)
161
+ continue;
162
+ try {
163
+ const name = JSON.parse(raw).name;
164
+ if (typeof name === "string" && name)
165
+ out.add(name);
166
+ }
167
+ catch {
168
+ // same
169
+ }
170
+ }
171
+ }
172
+ const parent = join(dir, "..");
173
+ if (parent === dir)
174
+ break;
175
+ dir = parent;
176
+ }
177
+ return [...out];
178
+ }
127
179
  async function detectStack(root) {
128
180
  const stack = [];
129
181
  const has = async (f) => (await readFile(join(root, f), "utf8").catch(() => null)) !== null;
@@ -267,13 +319,23 @@ export async function takeCensus(root) {
267
319
  const table = buildTable({ css, source: "yours" });
268
320
  const schemes = parseSchemeBlocks(css);
269
321
  const reports = [];
322
+ const tally = emptyTally();
323
+ const internal = await internalSpecifiers(root);
270
324
  for await (const file of walk(root)) {
271
325
  const src = await readFile(file, "utf8").catch(() => "");
272
326
  if (!src)
273
327
  continue;
274
- reports.push(scanSource(relative(root, file), src, table));
328
+ const rel = relative(root, file);
329
+ reports.push(scanSource(rel, src, table));
330
+ // Stories and tests compose components to SHOW them; counting those as the
331
+ // product's own composition is the same lie the colour census told before
332
+ // they were set aside.
333
+ if (!/(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/.test(rel)) {
334
+ scanComponentsInto(tally, rel, src, internal);
335
+ }
275
336
  }
276
337
  const d = diagnose(reports);
338
+ const components = tallyToInventory(tally);
277
339
  const pkgRaw = await readFile(join(root, "package.json"), "utf8").catch(() => null);
278
340
  let name = null;
279
341
  if (pkgRaw) {
@@ -292,6 +354,7 @@ export async function takeCensus(root) {
292
354
  ? { declaredAlt: Object.fromEntries(schemes.alt) }
293
355
  : {}),
294
356
  observed: distinctValues(d),
357
+ ...(components.length > 0 ? { components } : {}),
295
358
  totals: {
296
359
  scanned: d.scanned,
297
360
  values: d.findings.length,
@@ -323,6 +386,13 @@ function summarize(c) {
323
386
  if (c.declaredAlt && Object.keys(c.declaredAlt).length > 0) {
324
387
  console.log(body(`${Object.keys(c.declaredAlt).length} of them are declared again for dark - both schemes travel`));
325
388
  }
389
+ // The inventory, phase one: what this project composes. Printed, never
390
+ // mapped - deciding that their `Pill` is our `ds-badge` is a judgement a
391
+ // person approves, and it does not happen in a census.
392
+ if (c.components && c.components.length > 0) {
393
+ const withProps = c.components.filter((x) => Object.keys(x.props).length > 0).length;
394
+ console.log(body(`${c.components.length} components composed here, ${withProps} of them with props written literally`));
395
+ }
326
396
  if (c.totals.named > 0) {
327
397
  console.log(body(`${paint.strong(String(c.totals.named))} of those values ALREADY have a name in your system - ${c.totals.coverage}% of your design values come from it today`));
328
398
  }
@@ -346,6 +416,31 @@ function summarize(c) {
346
416
  * census stays arithmetic, and every value in `roles` must be one the census
347
417
  * already found - the endpoint drops anything else.
348
418
  */
419
+ /** The component inventory, in full, on a dry run - the whole point of a dry
420
+ * run being that you read what was measured before anything is sent. */
421
+ function printComponents(c) {
422
+ const list = c.components ?? [];
423
+ if (list.length === 0)
424
+ return;
425
+ console.log("");
426
+ const mine = list.filter((x) => !x.from);
427
+ const theirs = list.filter((x) => x.from);
428
+ console.log(section("What this project composes"));
429
+ if (theirs.length > 0) {
430
+ console.log(body(paint.faint(`${mine.length} written here, ${theirs.length} from packages (${[...new Set(theirs.map((x) => x.from))].slice(0, 3).join(", ")}…)`)));
431
+ console.log("");
432
+ }
433
+ for (const x of mine.slice(0, 12)) {
434
+ const props = Object.entries(x.props)
435
+ .slice(0, 3)
436
+ .map(([p, values]) => values.length > 0 ? `${p}(${values.slice(0, 4).join("|")})` : p)
437
+ .join(" ");
438
+ console.log(body(`${paint.strong(x.name.padEnd(18))} ${paint.faint(`${x.count}× in ${x.files} files`)} ${paint.dim(props)}`));
439
+ }
440
+ if (mine.length > 12) {
441
+ console.log(body(paint.faint(`(${mine.length - 12} more of yours)`)));
442
+ }
443
+ }
349
444
  function printAgentContract() {
350
445
  console.log("");
351
446
  console.log(section("If you are an agent reading this"));
@@ -500,6 +595,7 @@ export async function runImport(opts) {
500
595
  console.log(body(`Written to ${paint.strong(relative(root, out))}`));
501
596
  if (opts.dry) {
502
597
  console.log(body("Nothing was sent. Read the file, then run it without --dry."));
598
+ printComponents(census);
503
599
  printAgentContract();
504
600
  console.log("");
505
601
  return;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * WHAT COMPONENTS A PROJECT ACTUALLY USES, and with which props.
3
+ *
4
+ * The census reads vocabulary - colours, radii, spacing, fonts - and is blind
5
+ * to components. So a component library imported cleanly (dono, 30/07) arrived
6
+ * with thirteen colour ramps and not one of its eight atoms, and the system
7
+ * came out looking like ours instead of theirs.
8
+ *
9
+ * This is the same kind of arithmetic pointed at a different question: which
10
+ * elements does this code compose, how often, in how many files, and which
11
+ * literal values do their props take. No semantics, no judgement, no guessing
12
+ * what a component MEANS - that comparison happens later and with a person in
13
+ * the loop.
14
+ *
15
+ * A SCANNER, NOT A PARSER, and honest about it. The CLI ships no AST, so this
16
+ * reads JSX the way the drift scanner reads colours: by shape. It therefore
17
+ * sees what is written literally and misses what is computed
18
+ * (`<Comp {...props} />` contributes a use and no prop values), which is the
19
+ * right failure - a census that guessed at spread props would be inventing
20
+ * usage nobody wrote.
21
+ */
22
+ /**
23
+ * Opens a JSX element whose name is capitalised - which is React's own rule for
24
+ * "this is a component, not an html tag".
25
+ *
26
+ * The name allows dots (`Card.Header`) and the body is captured lazily up to
27
+ * the first `>` that is not inside a quoted value.
28
+ */
29
+ const ELEMENT = /<([A-Z][A-Za-z0-9_]*(?:\.[A-Z][A-Za-z0-9_]*)*)(\s[^>]*?)?\/?>/gs;
30
+ /** `variant="primary"` or `variant={"primary"}` - a literal a person typed. */
31
+ const LITERAL_PROP = /([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*\{?\s*["']([^"']*)["']\s*\}?/g;
32
+ /** `dense` with no value is a boolean prop set to true, and that IS a literal
33
+ * decision worth counting. */
34
+ const BARE_PROP = /(^|\s)([a-z][a-zA-Z0-9_]*)(?=\s|$)/g;
35
+ /**
36
+ * A generic in type position (`useState<Foo>()`, `Array<Item>`) is not an
37
+ * element. The tell is what precedes the `<`: an identifier or a closing paren
38
+ * means a type argument, while JSX follows `(`, `{`, `>`, a newline or nothing.
39
+ */
40
+ function looksLikeType(source, at) {
41
+ for (let i = at - 1; i >= 0; i--) {
42
+ const c = source[i];
43
+ if (c === " " || c === "\t")
44
+ continue;
45
+ return /[A-Za-z0-9_)\]]/.test(c);
46
+ }
47
+ return false;
48
+ }
49
+ /**
50
+ * Props that carry no design decision.
51
+ *
52
+ * `className` is the loudest and the least useful: its value is a Tailwind
53
+ * string, which the drift scanner already reads value by value, and listing it
54
+ * here buries the one prop that matters (`variant`, `tone`, `size`) under forty
55
+ * utility classes. The rest are plumbing or accessibility, never a variant axis.
56
+ */
57
+ const NOT_A_DECISION = /^(class|className|style|key|ref|id|href|src|alt|type|name|value|placeholder|children|on[A-Z]|data-|aria-)/;
58
+ /** `import X, { A, B as C } from "spec"` - who owns each local name. */
59
+ const IMPORT = /import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
60
+ function importedNames(source, internal = []) {
61
+ const out = new Map();
62
+ for (const m of source.matchAll(IMPORT)) {
63
+ const clause = m[1];
64
+ const spec = m[2];
65
+ // The project's own code, by three signals: a relative path, the default
66
+ // alias, or a prefix the workspace declares for itself (a tsconfig path or
67
+ // a sibling package's name). Without the third, `@ui/lib/SignalUI/atoms/Text`
68
+ // - a company's own component behind its own alias - was filed as a
69
+ // third-party package (dono, 30/07).
70
+ if (/^[./]/.test(spec) || spec.startsWith("@/"))
71
+ continue;
72
+ if (internal.some((pre) => spec === pre || spec.startsWith(`${pre}/`))) {
73
+ continue;
74
+ }
75
+ for (const raw of clause.replace(/[{}]/g, ",").split(",")) {
76
+ const part = raw.trim();
77
+ if (!part || part === "*")
78
+ continue;
79
+ const local = (part.split(/\s+as\s+/).pop() ?? "").trim();
80
+ if (/^[A-Z]/.test(local))
81
+ out.set(local, spec);
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ export function emptyTally() {
87
+ return new Map();
88
+ }
89
+ /** Fold one file into the tally. */
90
+ export function scanComponentsInto(tally, file, source,
91
+ /** Specifier prefixes the workspace owns - tsconfig paths and sibling
92
+ * package names. Anything matching is the project's own code. */
93
+ internal = []) {
94
+ if (!/\.(tsx|jsx|vue|svelte)$/i.test(file))
95
+ return;
96
+ const owners = importedNames(source, internal);
97
+ ELEMENT.lastIndex = 0;
98
+ for (const m of source.matchAll(ELEMENT)) {
99
+ if (m.index != null && looksLikeType(source, m.index))
100
+ continue;
101
+ const name = m[1];
102
+ const body = m[2] ?? "";
103
+ let hit = tally.get(name);
104
+ if (!hit) {
105
+ // A dotted name belongs to whoever exported its root (`Popover.Root`).
106
+ const owner = owners.get(name) ?? owners.get(name.split(".")[0]);
107
+ hit = {
108
+ count: 0,
109
+ files: new Set(),
110
+ props: new Map(),
111
+ ...(owner ? { from: owner } : {}),
112
+ };
113
+ tally.set(name, hit);
114
+ }
115
+ hit.count += 1;
116
+ hit.files.add(file);
117
+ LITERAL_PROP.lastIndex = 0;
118
+ const named = new Set();
119
+ for (const p of body.matchAll(LITERAL_PROP)) {
120
+ const [, prop, value] = p;
121
+ named.add(prop);
122
+ if (NOT_A_DECISION.test(prop))
123
+ continue;
124
+ const bucket = hit.props.get(prop) ?? new Map();
125
+ bucket.set(value, (bucket.get(value) ?? 0) + 1);
126
+ hit.props.set(prop, bucket);
127
+ }
128
+ // Bare booleans, minus anything already read as a valued prop.
129
+ // Strip the WHOLE pair, name included: stripping only the value left
130
+ // `onClose` standing alone, and the bare-boolean pass then recorded a
131
+ // callback as a decision somebody made.
132
+ const withoutValues = body
133
+ .replace(LITERAL_PROP, " ")
134
+ .replace(/[a-zA-Z][\w-]*\s*=\s*\{[^}]*\}/g, " ");
135
+ BARE_PROP.lastIndex = 0;
136
+ for (const b of withoutValues.matchAll(BARE_PROP)) {
137
+ const prop = b[2];
138
+ if (named.has(prop) || NOT_A_DECISION.test(prop))
139
+ continue;
140
+ const bucket = hit.props.get(prop) ?? new Map();
141
+ bucket.set("true", (bucket.get("true") ?? 0) + 1);
142
+ hit.props.set(prop, bucket);
143
+ }
144
+ }
145
+ }
146
+ /** The inventory, commonest first. */
147
+ export function tallyToInventory(tally, max = 80) {
148
+ return [...tally.entries()]
149
+ .map(([name, v]) => ({
150
+ name,
151
+ ...(v.from ? { from: v.from } : {}),
152
+ count: v.count,
153
+ files: v.files.size,
154
+ props: Object.fromEntries([...v.props.entries()].map(([p, values]) => [
155
+ p,
156
+ [...values.entries()]
157
+ .sort((a, b) => b[1] - a[1])
158
+ .map(([value]) => value),
159
+ ])),
160
+ }))
161
+ .sort((a, b) => b.files - a.files || b.count - a.count)
162
+ .slice(0, max);
163
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.44",
3
+ "version": "0.16.45",
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": {