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.
package/dist/commands/doctor.js
CHANGED
|
@@ -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"));
|
package/dist/commands/import.js
CHANGED
|
@@ -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
|
-
|
|
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