synthesisui 0.16.50 → 0.16.52

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.
@@ -3,7 +3,8 @@ 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
5
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
6
- import { crosswalk } from "../doctor/crosswalk.js";
6
+ import { crosswalk, observedRules } from "../doctor/crosswalk.js";
7
+ import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
7
8
  import { diagnose, scanSource } from "../doctor/scan.js";
8
9
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
9
10
  import { buildTable } from "../doctor/tokens.js";
@@ -271,6 +272,7 @@ export async function takeCensus(root) {
271
272
  const reports = [];
272
273
  const tally = emptyTally();
273
274
  const internal = await internalSpecifiers(root);
275
+ const defined = [];
274
276
  for await (const file of walk(root)) {
275
277
  const src = await readFile(file, "utf8").catch(() => "");
276
278
  if (!src)
@@ -282,6 +284,9 @@ export async function takeCensus(root) {
282
284
  // they were set aside.
283
285
  if (!/(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/.test(rel)) {
284
286
  scanComponentsInto(tally, rel, src, internal);
287
+ // The other half: what this file EXPORTS, with the axes its types
288
+ // declare. A library composes almost nothing and exports everything.
289
+ defined.push(...scanDefinitions(rel, src));
285
290
  }
286
291
  }
287
292
  const d = diagnose(reports);
@@ -293,9 +298,14 @@ export async function takeCensus(root) {
293
298
  r.component.name,
294
299
  { bucket: r.bucket, canonical: r.canonical, because: r.because },
295
300
  ]));
301
+ const laws = new Map();
302
+ for (const r of observedRules(inventory)) {
303
+ laws.set(r.component, [...(laws.get(r.component) ?? []), r.text]);
304
+ }
296
305
  const components = inventory.map((c) => ({
297
306
  ...c,
298
307
  ...(verdicts.get(c.name) ?? {}),
308
+ ...(laws.has(c.name) ? { laws: laws.get(c.name) } : {}),
299
309
  }));
300
310
  const pkgRaw = await readFile(join(root, "package.json"), "utf8").catch(() => null);
301
311
  let name = null;
@@ -316,6 +326,7 @@ export async function takeCensus(root) {
316
326
  : {}),
317
327
  observed: distinctValues(d),
318
328
  ...(components.length > 0 ? { components } : {}),
329
+ ...(defined.length > 0 ? { defined } : {}),
319
330
  totals: {
320
331
  scanned: d.scanned,
321
332
  values: d.findings.length,
@@ -402,6 +413,77 @@ function printComponents(c) {
402
413
  console.log(body(paint.faint(`(${mine.length - 12} more of yours)`)));
403
414
  }
404
415
  printCrosswalk(mine);
416
+ printObserved(mine);
417
+ printDefined(c, mine);
418
+ }
419
+ /**
420
+ * What the project DEFINES, and what crossing that with usage reveals.
421
+ *
422
+ * A library exports components rather than composing them, so a usage-only
423
+ * reading of `packages/ui` found thirteen of its thirty-one (dono, 31/07).
424
+ * This is the other half, and the crossing is the part no toolchain offers.
425
+ */
426
+ function printDefined(census, used) {
427
+ const defined = census.defined ?? [];
428
+ if (defined.length === 0)
429
+ return;
430
+ const withAxes = defined.filter((d) => Object.keys(d.axes).length > 0);
431
+ console.log("");
432
+ console.log(section("What this project defines"));
433
+ console.log(body(`${defined.length} components exported here, ${withAxes.length} with axes their types declare`));
434
+ console.log("");
435
+ for (const d of withAxes.slice(0, 8)) {
436
+ const axes = Object.entries(d.axes)
437
+ .map(([a, o]) => `${a}(${o.join("|")})`)
438
+ .join(" ");
439
+ console.log(body(` ${paint.strong(d.name.padEnd(20))} ${paint.dim(axes)}`));
440
+ }
441
+ if (withAxes.length > 8) {
442
+ console.log(body(paint.faint(` (${withAxes.length - 8} more)`)));
443
+ }
444
+ const crossed = reconcile(defined, used);
445
+ const dead = crossed.filter((r) => r.deadOptions.length > 0);
446
+ const stray = crossed.filter((r) => r.undeclared.length > 0);
447
+ const orphans = crossed.filter((r) => r.orphan);
448
+ if (dead.length === 0 && stray.length === 0 && orphans.length === 0)
449
+ return;
450
+ console.log("");
451
+ console.log(section("What the two readings disagree about"));
452
+ for (const r of stray.slice(0, 4)) {
453
+ for (const u of r.undeclared) {
454
+ console.log(body(` ${paint.strong(r.name)} is passed ${u.axis}="${u.values.join('" | "')}" ${paint.faint("- its own type does not offer that")}`));
455
+ }
456
+ }
457
+ for (const r of dead.slice(0, 4)) {
458
+ for (const d of r.deadOptions) {
459
+ console.log(body(` ${paint.strong(r.name)} declares ${d.axis}="${d.options.join('" | "')}" ${paint.faint("- and nothing ever passes it")}`));
460
+ }
461
+ }
462
+ if (orphans.length > 0) {
463
+ console.log(body(paint.dim(` ${orphans.length} defined here and composed nowhere in this folder - expected in a library, worth a look in an app.`)));
464
+ }
465
+ }
466
+ /**
467
+ * Laws their code already obeys, printed with the evidence that found them.
468
+ *
469
+ * Not applied and not sent as decisions - they ride along on the contract as
470
+ * `usage`, which is where a law about one component belongs and what the GUIDE
471
+ * and the build script already read.
472
+ */
473
+ function printObserved(mine) {
474
+ const rules = observedRules(mine);
475
+ if (rules.length === 0)
476
+ return;
477
+ console.log("");
478
+ console.log(section("Laws your code already obeys"));
479
+ console.log(body(paint.faint("A prop with one value across many files is not a choice being made - it is a rule nobody wrote down.")));
480
+ console.log("");
481
+ for (const r of rules.slice(0, 8)) {
482
+ console.log(body(` ${paint.strong(r.component)} ${paint.dim(r.text.replace(/^Always /, "always "))}`));
483
+ }
484
+ if (rules.length > 8) {
485
+ console.log(body(paint.faint(` (${rules.length - 8} more)`)));
486
+ }
405
487
  }
406
488
  /**
407
489
  * The crosswalk, printed and nothing else.
@@ -106,6 +106,7 @@ internal = []) {
106
106
  count: 0,
107
107
  files: new Set(),
108
108
  props: new Map(),
109
+ propFiles: new Map(),
109
110
  ...(owner ? { from: owner } : {}),
110
111
  };
111
112
  tally.set(name, hit);
@@ -122,6 +123,9 @@ internal = []) {
122
123
  const bucket = hit.props.get(prop) ?? new Map();
123
124
  bucket.set(value, (bucket.get(value) ?? 0) + 1);
124
125
  hit.props.set(prop, bucket);
126
+ const seenIn = hit.propFiles.get(prop) ?? new Set();
127
+ seenIn.add(file);
128
+ hit.propFiles.set(prop, seenIn);
125
129
  }
126
130
  // Bare booleans, minus anything already read as a valued prop.
127
131
  // Strip the WHOLE pair, name included: stripping only the value left
@@ -138,6 +142,9 @@ internal = []) {
138
142
  const bucket = hit.props.get(prop) ?? new Map();
139
143
  bucket.set("true", (bucket.get("true") ?? 0) + 1);
140
144
  hit.props.set(prop, bucket);
145
+ const seenIn = hit.propFiles.get(prop) ?? new Set();
146
+ seenIn.add(file);
147
+ hit.propFiles.set(prop, seenIn);
141
148
  }
142
149
  }
143
150
  }
@@ -149,6 +156,7 @@ export function tallyToInventory(tally, max = 80) {
149
156
  ...(v.from ? { from: v.from } : {}),
150
157
  count: v.count,
151
158
  files: v.files.size,
159
+ propFiles: Object.fromEntries([...v.propFiles.entries()].map(([p, files]) => [p, files.size])),
152
160
  props: Object.fromEntries([...v.props.entries()].map(([p, values]) => [
153
161
  p,
154
162
  [...values.entries()]
@@ -337,3 +337,41 @@ export function crosswalk(components) {
337
337
  })
338
338
  .sort((a, b) => b.component.files - a.component.files);
339
339
  }
340
+ /** Below this a repetition is a coincidence rather than a habit. */
341
+ export const RULE_MIN_FILES = 3;
342
+ export function observedRules(components) {
343
+ const out = [];
344
+ for (const c of components) {
345
+ if (c.from)
346
+ continue;
347
+ for (const [prop, values] of Object.entries(c.props)) {
348
+ if (values.length !== 1)
349
+ continue;
350
+ const value = values[0];
351
+ // A law is about a DESIGN AXIS or a flag. `marginBottom="1em"` and
352
+ // `textAlign="center"` are CSS escaping through a prop - a real finding,
353
+ // and a different one, but writing "always textAlign=center" as a law of
354
+ // the component is nonsense (dono, 31/07).
355
+ const isAxis = AXIS_SYNONYM[prop.toLowerCase()] != null;
356
+ if (!isAxis && value !== "true")
357
+ continue;
358
+ // The prop's OWN reach, never the component's: `Text` lives in 66 files
359
+ // and `textAlign` may appear in one of them, and the first version
360
+ // claimed all 66 agreed.
361
+ const files = c.propFiles?.[prop] ?? 0;
362
+ if (files < RULE_MIN_FILES)
363
+ continue;
364
+ out.push({
365
+ component: c.name,
366
+ prop,
367
+ value,
368
+ files,
369
+ count: c.count,
370
+ text: value === "true"
371
+ ? `Always set ${prop} - every one of the ${files} files that passes it does.`
372
+ : `Always ${prop}="${value}" - all ${files} files that pass it agree.`,
373
+ });
374
+ }
375
+ }
376
+ return out.sort((a, b) => b.files - a.files || b.count - a.count);
377
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * WHAT A PROJECT DEFINES, as opposed to what it composes.
3
+ *
4
+ * The inventory reads usage, and that is blind in exactly the place a component
5
+ * LIBRARY lives. Measured on a real one (dono, 31/07): `packages/ui` holds 31
6
+ * component directories and composes almost none of them internally - a library
7
+ * exports components, it does not use them - so a usage reader found thirteen
8
+ * and the other eighteen were invisible.
9
+ *
10
+ * And the definition is the better source anyway. A declared union IS the closed
11
+ * set, verbatim:
12
+ *
13
+ * type ButtonProps = { variant?: "neutral" | "ocean" }
14
+ *
15
+ * Usage can only ever show the options somebody happened to pick; the type shows
16
+ * the ones the author designed, including the one nobody has chosen yet - which
17
+ * is a finding of its own once the two readings are crossed.
18
+ *
19
+ * A SCANNER, NOT A PARSER, like everything else here. It reads the shapes people
20
+ * actually write and stays silent on the rest: a props type built by `Omit<>` or
21
+ * spread from another interface yields no axes rather than wrong ones.
22
+ */
23
+ /** `export function X`, `export default function X`, `export const X =`. */
24
+ const EXPORTED = /export\s+(?:default\s+)?(?:async\s+)?(?:function\s+([A-Z][A-Za-z0-9_]*)|const\s+([A-Z][A-Za-z0-9_]*)\s*[:=])/g;
25
+ /** `type ButtonProps = { … }` or `interface ButtonProps { … }`, to its closing
26
+ * brace, wherever it sits.
27
+ *
28
+ * Two stricter versions failed a spec each: requiring the brace at column zero
29
+ * missed an indented declaration, and requiring it on its own line missed a
30
+ * one-liner. Ending at the first `}` can cut a nested object short, and that is
31
+ * the safe direction - the field reader below simply finds no literal union in
32
+ * the fragment and stays quiet. */
33
+ const PROPS_BLOCK = /(?:type|interface)\s+([A-Z][A-Za-z0-9_]*?)Props\b[^{]*\{([\s\S]*?)\}/g;
34
+ /** One field of a props type: `variant?: "a" | "b"`. Fields are separated by a
35
+ * newline OR a semicolon, and a one-line type uses only the second. */
36
+ const FIELD = /(?:^|[;{])\s*(\w+)\??\s*:\s*([^;\n}]+)/gm;
37
+ /** A union made only of string literals - the shape of a designed axis. */
38
+ const LITERAL_UNION = /^\s*(["'][^"']+["']\s*\|\s*)+["'][^"']+["']\s*$/;
39
+ export function scanDefinitions(file, source) {
40
+ if (!/\.(tsx|jsx|vue|svelte)$/i.test(file))
41
+ return [];
42
+ // Props types first, keyed by the component name they belong to: `ButtonProps`
43
+ // is Button's. Matching by name prefix is the convention essentially every
44
+ // codebase follows, and guessing past it would attach axes to the wrong thing.
45
+ const propsOf = new Map();
46
+ PROPS_BLOCK.lastIndex = 0;
47
+ for (const block of source.matchAll(PROPS_BLOCK)) {
48
+ const owner = block[1];
49
+ const body = block[2];
50
+ const axes = {};
51
+ const flags = [];
52
+ FIELD.lastIndex = 0;
53
+ for (const f of body.matchAll(FIELD)) {
54
+ const prop = f[1];
55
+ const type = f[2].trim().replace(/,$/, "");
56
+ if (type === "boolean") {
57
+ flags.push(prop);
58
+ continue;
59
+ }
60
+ if (!LITERAL_UNION.test(type))
61
+ continue;
62
+ const options = [...type.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
63
+ // One option is not a closed set - the same rule the contract writer and
64
+ // the doctor already follow.
65
+ if (options.length >= 2)
66
+ axes[prop] = options;
67
+ }
68
+ if (Object.keys(axes).length > 0 || flags.length > 0) {
69
+ propsOf.set(owner, { axes, flags });
70
+ }
71
+ }
72
+ const out = [];
73
+ const seen = new Set();
74
+ EXPORTED.lastIndex = 0;
75
+ for (const m of source.matchAll(EXPORTED)) {
76
+ const name = m[1] ?? m[2];
77
+ if (!name || seen.has(name))
78
+ continue;
79
+ seen.add(name);
80
+ const props = propsOf.get(name);
81
+ out.push({
82
+ name,
83
+ file,
84
+ axes: props?.axes ?? {},
85
+ flags: props?.flags ?? [],
86
+ });
87
+ }
88
+ return out;
89
+ }
90
+ export function reconcile(defined, used) {
91
+ const usage = new Map(used.filter((u) => !u.from).map((u) => [u.name, u]));
92
+ const out = [];
93
+ for (const d of defined) {
94
+ const u = usage.get(d.name);
95
+ const deadOptions = [];
96
+ const undeclared = [];
97
+ for (const [axis, options] of Object.entries(d.axes)) {
98
+ const passed = new Set((u?.props[axis] ?? []).map((v) => v.trim().toLowerCase()));
99
+ const dead = options.filter((o) => !passed.has(o.toLowerCase()));
100
+ // Every option unused means the axis is untouched, not that each option
101
+ // is dead - reporting the whole set as dead reads as an accusation about
102
+ // options rather than about an axis nobody reaches for.
103
+ if (dead.length > 0 && dead.length < options.length) {
104
+ deadOptions.push({ axis, options: dead });
105
+ }
106
+ const declared = new Set(options.map((o) => o.toLowerCase()));
107
+ const stray = (u?.props[axis] ?? []).filter((v) => v !== "true" && !declared.has(v.trim().toLowerCase()));
108
+ if (stray.length > 0)
109
+ undeclared.push({ axis, values: stray });
110
+ }
111
+ if (deadOptions.length > 0 || undeclared.length > 0 || !u) {
112
+ out.push({ name: d.name, deadOptions, undeclared, orphan: !u });
113
+ }
114
+ }
115
+ return out;
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.50",
3
+ "version": "0.16.52",
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": {