synthesisui 0.16.49 → 0.16.51

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,8 +2,8 @@ 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";
6
- import { crosswalk } from "../doctor/crosswalk.js";
5
+ import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
6
+ import { crosswalk, observedRules } from "../doctor/crosswalk.js";
7
7
  import { diagnose, scanSource } from "../doctor/scan.js";
8
8
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
9
9
  import { buildTable } from "../doctor/tokens.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;
@@ -344,9 +293,14 @@ export async function takeCensus(root) {
344
293
  r.component.name,
345
294
  { bucket: r.bucket, canonical: r.canonical, because: r.because },
346
295
  ]));
296
+ const laws = new Map();
297
+ for (const r of observedRules(inventory)) {
298
+ laws.set(r.component, [...(laws.get(r.component) ?? []), r.text]);
299
+ }
347
300
  const components = inventory.map((c) => ({
348
301
  ...c,
349
302
  ...(verdicts.get(c.name) ?? {}),
303
+ ...(laws.has(c.name) ? { laws: laws.get(c.name) } : {}),
350
304
  }));
351
305
  const pkgRaw = await readFile(join(root, "package.json"), "utf8").catch(() => null);
352
306
  let name = null;
@@ -453,6 +407,29 @@ function printComponents(c) {
453
407
  console.log(body(paint.faint(`(${mine.length - 12} more of yours)`)));
454
408
  }
455
409
  printCrosswalk(mine);
410
+ printObserved(mine);
411
+ }
412
+ /**
413
+ * Laws their code already obeys, printed with the evidence that found them.
414
+ *
415
+ * Not applied and not sent as decisions - they ride along on the contract as
416
+ * `usage`, which is where a law about one component belongs and what the GUIDE
417
+ * and the build script already read.
418
+ */
419
+ function printObserved(mine) {
420
+ const rules = observedRules(mine);
421
+ if (rules.length === 0)
422
+ return;
423
+ console.log("");
424
+ console.log(section("Laws your code already obeys"));
425
+ 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.")));
426
+ console.log("");
427
+ for (const r of rules.slice(0, 8)) {
428
+ console.log(body(` ${paint.strong(r.component)} ${paint.dim(r.text.replace(/^Always /, "always "))}`));
429
+ }
430
+ if (rules.length > 8) {
431
+ console.log(body(paint.faint(` (${rules.length - 8} more)`)));
432
+ }
456
433
  }
457
434
  /**
458
435
  * The crosswalk, printed and nothing else.
@@ -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".
@@ -125,6 +106,7 @@ internal = []) {
125
106
  count: 0,
126
107
  files: new Set(),
127
108
  props: new Map(),
109
+ propFiles: new Map(),
128
110
  ...(owner ? { from: owner } : {}),
129
111
  };
130
112
  tally.set(name, hit);
@@ -141,6 +123,9 @@ internal = []) {
141
123
  const bucket = hit.props.get(prop) ?? new Map();
142
124
  bucket.set(value, (bucket.get(value) ?? 0) + 1);
143
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);
144
129
  }
145
130
  // Bare booleans, minus anything already read as a valued prop.
146
131
  // Strip the WHOLE pair, name included: stripping only the value left
@@ -157,6 +142,9 @@ internal = []) {
157
142
  const bucket = hit.props.get(prop) ?? new Map();
158
143
  bucket.set("true", (bucket.get("true") ?? 0) + 1);
159
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);
160
148
  }
161
149
  }
162
150
  }
@@ -168,6 +156,7 @@ export function tallyToInventory(tally, max = 80) {
168
156
  ...(v.from ? { from: v.from } : {}),
169
157
  count: v.count,
170
158
  files: v.files.size,
159
+ propFiles: Object.fromEntries([...v.propFiles.entries()].map(([p, files]) => [p, files.size])),
171
160
  props: Object.fromEntries([...v.props.entries()].map(([p, values]) => [
172
161
  p,
173
162
  [...values.entries()]
@@ -178,3 +167,54 @@ export function tallyToInventory(tally, max = 80) {
178
167
  .sort((a, b) => b.files - a.files || b.count - a.count)
179
168
  .slice(0, max);
180
169
  }
170
+ /**
171
+ * Specifier prefixes this workspace owns: the aliases its tsconfig declares and
172
+ * the names of its sibling packages. Everything else that is not relative comes
173
+ * from node_modules.
174
+ */
175
+ export async function internalSpecifiers(root) {
176
+ const out = new Set();
177
+ for (let up = 0, dir = root; up < 4; up++) {
178
+ for (const f of ["tsconfig.json", "tsconfig.base.json"]) {
179
+ const raw = await readFile(join(dir, f), "utf8").catch(() => null);
180
+ if (!raw)
181
+ continue;
182
+ try {
183
+ // tsconfig allows comments and trailing commas; a census must not die
184
+ // on either, and the keys are all we want.
185
+ for (const m of raw.matchAll(/"([^"]+)\/?\*?"\s*:\s*\[/g)) {
186
+ const key = m[1].replace(/\/\*$/, "").replace(/\*$/, "");
187
+ if (key && !key.startsWith("."))
188
+ out.add(key.replace(/\/$/, ""));
189
+ }
190
+ }
191
+ catch {
192
+ // unreadable config costs the aliases, not the run
193
+ }
194
+ }
195
+ for (const group of ["packages", "libs", "apps"]) {
196
+ for (const e of await readdir(join(dir, group), {
197
+ withFileTypes: true,
198
+ }).catch(() => [])) {
199
+ if (!e.isDirectory())
200
+ continue;
201
+ const raw = await readFile(join(dir, group, e.name, "package.json"), "utf8").catch(() => null);
202
+ if (!raw)
203
+ continue;
204
+ try {
205
+ const name = JSON.parse(raw).name;
206
+ if (typeof name === "string" && name)
207
+ out.add(name);
208
+ }
209
+ catch {
210
+ // same
211
+ }
212
+ }
213
+ }
214
+ const parent = join(dir, "..");
215
+ if (parent === dir)
216
+ break;
217
+ dir = parent;
218
+ }
219
+ return [...out];
220
+ }
@@ -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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.49",
3
+ "version": "0.16.51",
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": {