synthesisui 0.16.51 → 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 +54 -0
- package/dist/doctor/definitions-scan.js +116 -0
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
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);
|
|
@@ -321,6 +326,7 @@ export async function takeCensus(root) {
|
|
|
321
326
|
: {}),
|
|
322
327
|
observed: distinctValues(d),
|
|
323
328
|
...(components.length > 0 ? { components } : {}),
|
|
329
|
+
...(defined.length > 0 ? { defined } : {}),
|
|
324
330
|
totals: {
|
|
325
331
|
scanned: d.scanned,
|
|
326
332
|
values: d.findings.length,
|
|
@@ -408,6 +414,54 @@ function printComponents(c) {
|
|
|
408
414
|
}
|
|
409
415
|
printCrosswalk(mine);
|
|
410
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
|
+
}
|
|
411
465
|
}
|
|
412
466
|
/**
|
|
413
467
|
* Laws their code already obeys, printed with the evidence that found them.
|
|
@@ -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