synthesisui 0.16.47 → 0.16.50

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,6 +1,8 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
3
  import { findDivergences } from "../doctor/coherence.js";
4
+ import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
5
+ import { checkContracts } from "../doctor/contract-check.js";
4
6
  import { findFrozenBindings } from "../doctor/frozen.js";
5
7
  import { appendEvent, readEvents, summarize } from "../doctor/ledger.js";
6
8
  import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
@@ -29,6 +31,8 @@ import { body, paint, section, snippet } from "../output.js";
29
31
  * Runs offline, needs no account, and touches nothing. A tool that asks for a
30
32
  * signup before it tells you anything is a tool nobody runs twice.
31
33
  */
34
+ /** Files that compose components to SHOW them, not to ship them. */
35
+ const IS_ASIDE = /(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/;
32
36
  const EXTS = [".tsx", ".ts", ".jsx", ".js", ".css", ".scss", ".vue", ".svelte"];
33
37
  const SKIP = new Set([
34
38
  "node_modules",
@@ -404,6 +408,8 @@ export async function doctor(opts) {
404
408
  */
405
409
  const wiring = await readWiring(root, table.slug);
406
410
  const skippedProjects = [];
411
+ const tally = emptyTally();
412
+ const internalSpecs = await internalSpecifiers(root);
407
413
  for await (const file of scopes.length > 0
408
414
  ? walkAll(scopes)
409
415
  : walk(root, skippedProjects)) {
@@ -412,6 +418,13 @@ export async function doctor(opts) {
412
418
  continue;
413
419
  const rel = relative(root, file);
414
420
  reports.push(scanSource(rel, src, table));
421
+ // Composition, alongside values: the contract check needs to know which
422
+ // elements this file writes and with what options. Stories and tests are
423
+ // excluded for the reason they are excluded everywhere else - they compose
424
+ // components to SHOW them.
425
+ if (documents.length > 0 && !IS_ASIDE.test(rel)) {
426
+ scanComponentsInto(tally, rel, src, internalSpecs);
427
+ }
415
428
  if (recipes.size > 0) {
416
429
  for (const o of findOverrides(src, recipes))
417
430
  overrides.push({ ...o, file: rel });
@@ -433,6 +446,7 @@ export async function doctor(opts) {
433
446
  for (const r of reports) {
434
447
  r.findings = r.findings.filter((f) => !explained.has(`${r.file}:${f.line}:${f.literal.toLowerCase()}`));
435
448
  }
449
+ const breaches = checkContracts(tallyToInventory(tally, 400), documents);
436
450
  const d = diagnose(reports);
437
451
  // Nine releases in one evening added a section each, every one justified on
438
452
  // its own, and nobody read the whole. The result was 151 lines carrying about
@@ -1081,6 +1095,35 @@ export async function doctor(opts) {
1081
1095
  : "")));
1082
1096
  }
1083
1097
  }
1098
+ /**
1099
+ * A CONTRACT BROKEN, which is the first thing this command has ever said
1100
+ * about composition rather than about values.
1101
+ *
1102
+ * A colour outside the palette has always been drift. This is the same
1103
+ * sentence about a component: `<WidgetCard variant="danger">` on a
1104
+ * component whose contract offers `neutral` and `ocean`. No linter and no
1105
+ * type can say it, because the answer lives in the design system.
1106
+ */
1107
+ if (breaches.length > 0) {
1108
+ // Grouped by element AND axis: grouping by element alone printed
1109
+ // `variant="danger" | "huge"` and then offered the options for variant,
1110
+ // when `huge` was a size. One line per axis or the line lies.
1111
+ const byAxis = new Map();
1112
+ for (const b of breaches) {
1113
+ const k = `${b.element}\u0000${b.axis}`;
1114
+ byAxis.set(k, [...(byAxis.get(k) ?? []), b]);
1115
+ }
1116
+ console.log("");
1117
+ console.log(body(`${paint.strong(String(breaches.length))} use${breaches.length === 1 ? "" : "s"} outside the contract`));
1118
+ for (const list of [...byAxis.values()].slice(0, 5)) {
1119
+ const first = list[0];
1120
+ const values = [...new Set(list.map((b) => b.used))];
1121
+ console.log(body(` <${first.element} ${first.axis}="${values.join('" | "')}"> ${paint.faint(`- your ds-${first.recipe} offers ${first.offered.join(", ")}`)}`));
1122
+ }
1123
+ if (byAxis.size > 5) {
1124
+ console.log(body(paint.faint(` (${byAxis.size - 5} more)`)));
1125
+ }
1126
+ }
1084
1127
  if (plan.length > 0) {
1085
1128
  console.log("");
1086
1129
  console.log(body("Where to start"));
@@ -2,7 +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
+ import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
6
6
  import { crosswalk } from "../doctor/crosswalk.js";
7
7
  import { diagnose, scanSource } from "../doctor/scan.js";
8
8
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
@@ -126,57 +126,6 @@ async function resolveDeps(root) {
126
126
  }
127
127
  return deps;
128
128
  }
129
- /**
130
- * Specifier prefixes this workspace owns: the aliases its tsconfig declares and
131
- * the names of its sibling packages. Everything else that is not relative comes
132
- * from node_modules.
133
- */
134
- async function internalSpecifiers(root) {
135
- const out = new Set();
136
- for (let up = 0, dir = root; up < 4; up++) {
137
- for (const f of ["tsconfig.json", "tsconfig.base.json"]) {
138
- const raw = await readFile(join(dir, f), "utf8").catch(() => null);
139
- if (!raw)
140
- continue;
141
- try {
142
- // tsconfig allows comments and trailing commas; a census must not die
143
- // on either, and the keys are all we want.
144
- for (const m of raw.matchAll(/"([^"]+)\/?\*?"\s*:\s*\[/g)) {
145
- const key = m[1].replace(/\/\*$/, "").replace(/\*$/, "");
146
- if (key && !key.startsWith("."))
147
- out.add(key.replace(/\/$/, ""));
148
- }
149
- }
150
- catch {
151
- // unreadable config costs the aliases, not the run
152
- }
153
- }
154
- for (const group of ["packages", "libs", "apps"]) {
155
- for (const e of await readdir(join(dir, group), {
156
- withFileTypes: true,
157
- }).catch(() => [])) {
158
- if (!e.isDirectory())
159
- continue;
160
- const raw = await readFile(join(dir, group, e.name, "package.json"), "utf8").catch(() => null);
161
- if (!raw)
162
- continue;
163
- try {
164
- const name = JSON.parse(raw).name;
165
- if (typeof name === "string" && name)
166
- out.add(name);
167
- }
168
- catch {
169
- // same
170
- }
171
- }
172
- }
173
- const parent = join(dir, "..");
174
- if (parent === dir)
175
- break;
176
- dir = parent;
177
- }
178
- return [...out];
179
- }
180
129
  async function detectStack(root) {
181
130
  const stack = [];
182
131
  const has = async (f) => (await readFile(join(root, f), "utf8").catch(() => null)) !== null;
@@ -336,7 +285,18 @@ export async function takeCensus(root) {
336
285
  }
337
286
  }
338
287
  const d = diagnose(reports);
339
- const components = tallyToInventory(tally);
288
+ const inventory = tallyToInventory(tally);
289
+ // The verdict travels WITH the payload: the platform reads one reading rather
290
+ // than computing a second opinion from the same numbers, which is how two
291
+ // implementations of the same judgement start disagreeing.
292
+ const verdicts = new Map(crosswalk(inventory).map((r) => [
293
+ r.component.name,
294
+ { bucket: r.bucket, canonical: r.canonical, because: r.because },
295
+ ]));
296
+ const components = inventory.map((c) => ({
297
+ ...c,
298
+ ...(verdicts.get(c.name) ?? {}),
299
+ }));
340
300
  const pkgRaw = await readFile(join(root, "package.json"), "utf8").catch(() => null);
341
301
  let name = null;
342
302
  if (pkgRaw) {
@@ -477,6 +437,20 @@ function printCrosswalk(mine) {
477
437
  show(`Already in the catalogue (${exists.length})`, exists, 6);
478
438
  show(`The same decision, twice (${nearly.length})`, nearly, 6);
479
439
  show(`Only yours (${exclusive.length})`, exclusive, 8);
440
+ // Counted, never listed: four icons and a provider under "only yours" pad the
441
+ // one list worth reading with the least interesting thing in it.
442
+ const icons = of("icon").length;
443
+ const providers = of("provider").length;
444
+ if (icons > 0 || providers > 0) {
445
+ const bits = [
446
+ icons > 0 &&
447
+ `${icons} icon${icons === 1 ? "" : "s"} (a library, not recipes)`,
448
+ providers > 0 &&
449
+ `${providers} provider${providers === 1 ? "" : "s"} (no UI of their own)`,
450
+ ].filter(Boolean);
451
+ console.log(body(paint.faint(`Set aside: ${bits.join(" · ")}`)));
452
+ console.log("");
453
+ }
480
454
  console.log(body(paint.dim("Nothing was mapped. This is a reading - the adopting is a decision you make.")));
481
455
  }
482
456
  function printAgentContract() {
@@ -1,24 +1,5 @@
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
- */
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
22
3
  /**
23
4
  * Opens a JSX element whose name is capitalised - which is React's own rule for
24
5
  * "this is a component, not an html tag".
@@ -59,7 +40,19 @@ function looksLikeType(source, at) {
59
40
  * of copy wearing the shape of a variant axis. An axis is a closed set somebody
60
41
  * designed; a title is whatever that screen happens to say.
61
42
  */
62
- const NOT_A_DECISION = /^(class|className|style|key|ref|id|href|src|alt|type|name|value|placeholder|children|on[A-Z]|data-|aria-|title|label|text|heading|subtitle|caption|description|message|content|tooltip|hint|error|helper)$|^(class|className|on[A-Z]|data-|aria-)/;
43
+ const CONTENT_WORD = "class|className|style|key|ref|id|href|src|alt|type|name|value|placeholder|children|title|label|text|heading|subtitle|caption|description|message|content|tooltip|hint|error|helper";
44
+ /**
45
+ * Whole name, or the TAIL of a camelCase compound.
46
+ *
47
+ * `bodyClassName` and `infoContent` walked straight past a whole-name match
48
+ * (dono, 30/07), and `infoContent` brought four sentences of screen copy into
49
+ * what is supposed to be a list of variant axes. The tail is what says what a
50
+ * prop IS: `infoContent` is content, `bodyClassName` is a class name.
51
+ *
52
+ * Matching the tail rather than anywhere keeps `titleTone` - an axis whose name
53
+ * merely starts with a content word.
54
+ */
55
+ const NOT_A_DECISION = new RegExp(`^(?:${CONTENT_WORD})$|[a-z](?:${CONTENT_WORD.replace(/\b([a-z])/g, (_, c) => `[${c}${c.toUpperCase()}]`)})$|^(?:on[A-Z]|data-|aria-)`);
63
56
  /** `import X, { A, B as C } from "spec"` - who owns each local name. */
64
57
  const IMPORT = /import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
65
58
  function importedNames(source, internal = []) {
@@ -166,3 +159,54 @@ export function tallyToInventory(tally, max = 80) {
166
159
  .sort((a, b) => b.files - a.files || b.count - a.count)
167
160
  .slice(0, max);
168
161
  }
162
+ /**
163
+ * Specifier prefixes this workspace owns: the aliases its tsconfig declares and
164
+ * the names of its sibling packages. Everything else that is not relative comes
165
+ * from node_modules.
166
+ */
167
+ export async function internalSpecifiers(root) {
168
+ const out = new Set();
169
+ for (let up = 0, dir = root; up < 4; up++) {
170
+ for (const f of ["tsconfig.json", "tsconfig.base.json"]) {
171
+ const raw = await readFile(join(dir, f), "utf8").catch(() => null);
172
+ if (!raw)
173
+ continue;
174
+ try {
175
+ // tsconfig allows comments and trailing commas; a census must not die
176
+ // on either, and the keys are all we want.
177
+ for (const m of raw.matchAll(/"([^"]+)\/?\*?"\s*:\s*\[/g)) {
178
+ const key = m[1].replace(/\/\*$/, "").replace(/\*$/, "");
179
+ if (key && !key.startsWith("."))
180
+ out.add(key.replace(/\/$/, ""));
181
+ }
182
+ }
183
+ catch {
184
+ // unreadable config costs the aliases, not the run
185
+ }
186
+ }
187
+ for (const group of ["packages", "libs", "apps"]) {
188
+ for (const e of await readdir(join(dir, group), {
189
+ withFileTypes: true,
190
+ }).catch(() => [])) {
191
+ if (!e.isDirectory())
192
+ continue;
193
+ const raw = await readFile(join(dir, group, e.name, "package.json"), "utf8").catch(() => null);
194
+ if (!raw)
195
+ continue;
196
+ try {
197
+ const name = JSON.parse(raw).name;
198
+ if (typeof name === "string" && name)
199
+ out.add(name);
200
+ }
201
+ catch {
202
+ // same
203
+ }
204
+ }
205
+ }
206
+ const parent = join(dir, "..");
207
+ if (parent === dir)
208
+ break;
209
+ dir = parent;
210
+ }
211
+ return [...out];
212
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * DOES THE CODE STAY INSIDE THE CONTRACT IT SIGNED?
3
+ *
4
+ * A system that arrived from `import` carries its exclusive components as
5
+ * CONTRACTS: the axes declared, the styles empty. That was worth doing only if
6
+ * something checks them afterwards - otherwise the declaration is a comment.
7
+ *
8
+ * This is that check, and it is the first one the product makes about
9
+ * COMPOSITION rather than about values. A colour outside the palette has always
10
+ * been drift; now `<WidgetCard variant="danger">` is drift too, on a component
11
+ * whose contract offers `neutral` and `ocean` - and nothing else in the
12
+ * toolchain can say that, because the answer lives in the design system rather
13
+ * than in the types.
14
+ *
15
+ * Silent by construction. An axis the contract does not declare is not checked
16
+ * (the system has no opinion about it), a component the system does not carry is
17
+ * not checked, and a value that cannot be read as a literal is not guessed at.
18
+ * A check that fires on what it does not know teaches people to ignore it.
19
+ */
20
+ /** `WidgetCard` → `widget-card`, the same shape the contract was written under. */
21
+ export function recipeNameOf(element) {
22
+ return element
23
+ .split(".")
24
+ .join("-")
25
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9-]/g, "-")
28
+ .replace(/-+/g, "-")
29
+ .replace(/^-|-$/g, "");
30
+ }
31
+ /** axis → the options a recipe declares, read off the document. */
32
+ export function axesOfDocument(doc) {
33
+ const out = new Map();
34
+ const comps = doc?.components;
35
+ if (!comps)
36
+ return out;
37
+ for (const [name, recipe] of Object.entries(comps)) {
38
+ const variants = recipe
39
+ ?.variants;
40
+ if (!variants)
41
+ continue;
42
+ const axes = new Map();
43
+ for (const [axis, options] of Object.entries(variants)) {
44
+ const keys = Object.keys((options ?? {})).filter(Boolean);
45
+ // One option is not a closed set; the contract writer refuses to declare
46
+ // those, and this refuses to enforce them for the same reason.
47
+ if (keys.length >= 2)
48
+ axes.set(axis.toLowerCase(), keys);
49
+ }
50
+ if (axes.size > 0)
51
+ out.set(name.toLowerCase(), axes);
52
+ }
53
+ return out;
54
+ }
55
+ export function checkContracts(used, documents) {
56
+ const axes = new Map();
57
+ for (const doc of documents) {
58
+ for (const [name, a] of axesOfDocument(doc))
59
+ axes.set(name, a);
60
+ }
61
+ if (axes.size === 0)
62
+ return [];
63
+ const out = [];
64
+ for (const c of used) {
65
+ // A third party's component answers to its own package, not to this system.
66
+ if (c.from)
67
+ continue;
68
+ const recipe = recipeNameOf(c.name);
69
+ const contract = axes.get(recipe);
70
+ if (!contract)
71
+ continue;
72
+ for (const [prop, values] of Object.entries(c.props)) {
73
+ const offered = contract.get(prop.toLowerCase());
74
+ if (!offered)
75
+ continue;
76
+ const allowed = new Set(offered.map((o) => o.toLowerCase()));
77
+ for (const value of values) {
78
+ const v = value.trim().toLowerCase();
79
+ // `true` is what a bare boolean records; a contract about options has
80
+ // nothing to say about a flag.
81
+ if (!v || v === "true" || allowed.has(v))
82
+ continue;
83
+ out.push({
84
+ element: c.name,
85
+ recipe,
86
+ axis: prop,
87
+ used: value,
88
+ offered,
89
+ });
90
+ }
91
+ }
92
+ }
93
+ return out;
94
+ }
@@ -207,6 +207,26 @@ export function axisOverlap(a, b) {
207
207
  shared += 1;
208
208
  return shared / new Set([...va, ...vb]).size;
209
209
  }
210
+ /**
211
+ * Names that are not components in the sense this crosswalk means.
212
+ *
213
+ * An ICON is a glyph in a set, and a design system models it as a library
214
+ * rather than as a recipe with variant axes - so listing four of them under
215
+ * "only yours" pads the interesting list with the least interesting thing in
216
+ * it (dono, 30/07). A PROVIDER renders no UI at all.
217
+ *
218
+ * Both are reported as counts instead, which says more in one line than four
219
+ * lines of "nothing in the catalogue does this".
220
+ */
221
+ const IS_ICON = /(^Icon|Icon$)/;
222
+ const IS_PROVIDER = /(Provider|Context)$/;
223
+ export function classifyAside(name) {
224
+ if (IS_ICON.test(name))
225
+ return "icon";
226
+ if (IS_PROVIDER.test(name))
227
+ return "provider";
228
+ return null;
229
+ }
210
230
  /** Below this, two components share a value or two by accident. */
211
231
  export const TWIN_OVERLAP = 0.5;
212
232
  /**
@@ -254,6 +274,18 @@ export function crosswalk(components) {
254
274
  }
255
275
  return mine
256
276
  .map((component) => {
277
+ const aside = classifyAside(component.name);
278
+ if (aside) {
279
+ return {
280
+ component,
281
+ canonical: null,
282
+ twins: [],
283
+ bucket: aside,
284
+ because: aside === "icon"
285
+ ? "a glyph, which a system models as a library rather than a recipe"
286
+ : "renders no UI - infrastructure, not a component",
287
+ };
288
+ }
257
289
  const canonical = canonicalName(component.name);
258
290
  const twins = mine
259
291
  .filter((o) => o.name !== component.name)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.47",
3
+ "version": "0.16.50",
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": {