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.
package/dist/commands/import.js
CHANGED
|
@@ -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()]
|
package/dist/doctor/crosswalk.js
CHANGED
|
@@ -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