synthesisui 0.16.49 → 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;
@@ -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".
@@ -178,3 +159,54 @@ export function tallyToInventory(tally, max = 80) {
178
159
  .sort((a, b) => b.files - a.files || b.count - a.count)
179
160
  .slice(0, max);
180
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.49",
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": {