synthesisui 0.16.51 → 0.16.53
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 +84 -3
- 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,14 +284,44 @@ 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);
|
|
288
293
|
const inventory = tallyToInventory(tally);
|
|
294
|
+
/**
|
|
295
|
+
* THE TWO READINGS BECOME ONE LIST before anything is judged.
|
|
296
|
+
*
|
|
297
|
+
* The crosswalk saw only what a project COMPOSES, so a library's report said
|
|
298
|
+
* "only yours (4)" three lines above "37 components exported here" - the two
|
|
299
|
+
* sections talking past each other (dono, 31/07). Worse, the contracts that
|
|
300
|
+
* travel are picked from that list, so a library would have sent four.
|
|
301
|
+
*
|
|
302
|
+
* A component that is defined and never composed here is still a component:
|
|
303
|
+
* in a library that is the entire point. Its axes come from the TYPE, which
|
|
304
|
+
* is the better source anyway - usage shows what somebody picked, the
|
|
305
|
+
* declaration shows what the author designed.
|
|
306
|
+
*/
|
|
307
|
+
const composed = new Set(inventory.map((c) => c.name));
|
|
308
|
+
const fromTypes = defined
|
|
309
|
+
.filter((d) => !composed.has(d.name))
|
|
310
|
+
.map((d) => ({
|
|
311
|
+
name: d.name,
|
|
312
|
+
count: 0,
|
|
313
|
+
files: 0,
|
|
314
|
+
props: {
|
|
315
|
+
...Object.fromEntries(Object.entries(d.axes)),
|
|
316
|
+
...Object.fromEntries(d.flags.map((f) => [f, ["true"]])),
|
|
317
|
+
},
|
|
318
|
+
propFiles: {},
|
|
319
|
+
}));
|
|
320
|
+
const merged = [...inventory, ...fromTypes];
|
|
289
321
|
// The verdict travels WITH the payload: the platform reads one reading rather
|
|
290
322
|
// than computing a second opinion from the same numbers, which is how two
|
|
291
323
|
// implementations of the same judgement start disagreeing.
|
|
292
|
-
const verdicts = new Map(crosswalk(
|
|
324
|
+
const verdicts = new Map(crosswalk(merged).map((r) => [
|
|
293
325
|
r.component.name,
|
|
294
326
|
{ bucket: r.bucket, canonical: r.canonical, because: r.because },
|
|
295
327
|
]));
|
|
@@ -297,7 +329,7 @@ export async function takeCensus(root) {
|
|
|
297
329
|
for (const r of observedRules(inventory)) {
|
|
298
330
|
laws.set(r.component, [...(laws.get(r.component) ?? []), r.text]);
|
|
299
331
|
}
|
|
300
|
-
const components =
|
|
332
|
+
const components = merged.map((c) => ({
|
|
301
333
|
...c,
|
|
302
334
|
...(verdicts.get(c.name) ?? {}),
|
|
303
335
|
...(laws.has(c.name) ? { laws: laws.get(c.name) } : {}),
|
|
@@ -321,6 +353,7 @@ export async function takeCensus(root) {
|
|
|
321
353
|
: {}),
|
|
322
354
|
observed: distinctValues(d),
|
|
323
355
|
...(components.length > 0 ? { components } : {}),
|
|
356
|
+
...(defined.length > 0 ? { defined } : {}),
|
|
324
357
|
totals: {
|
|
325
358
|
scanned: d.scanned,
|
|
326
359
|
values: d.findings.length,
|
|
@@ -408,6 +441,54 @@ function printComponents(c) {
|
|
|
408
441
|
}
|
|
409
442
|
printCrosswalk(mine);
|
|
410
443
|
printObserved(mine);
|
|
444
|
+
printDefined(c, mine);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* What the project DEFINES, and what crossing that with usage reveals.
|
|
448
|
+
*
|
|
449
|
+
* A library exports components rather than composing them, so a usage-only
|
|
450
|
+
* reading of `packages/ui` found thirteen of its thirty-one (dono, 31/07).
|
|
451
|
+
* This is the other half, and the crossing is the part no toolchain offers.
|
|
452
|
+
*/
|
|
453
|
+
function printDefined(census, used) {
|
|
454
|
+
const defined = census.defined ?? [];
|
|
455
|
+
if (defined.length === 0)
|
|
456
|
+
return;
|
|
457
|
+
const withAxes = defined.filter((d) => Object.keys(d.axes).length > 0);
|
|
458
|
+
console.log("");
|
|
459
|
+
console.log(section("What this project defines"));
|
|
460
|
+
console.log(body(`${defined.length} components exported here, ${withAxes.length} with axes their types declare`));
|
|
461
|
+
console.log("");
|
|
462
|
+
for (const d of withAxes.slice(0, 8)) {
|
|
463
|
+
const axes = Object.entries(d.axes)
|
|
464
|
+
.map(([a, o]) => `${a}(${o.join("|")})`)
|
|
465
|
+
.join(" ");
|
|
466
|
+
console.log(body(` ${paint.strong(d.name.padEnd(20))} ${paint.dim(axes)}`));
|
|
467
|
+
}
|
|
468
|
+
if (withAxes.length > 8) {
|
|
469
|
+
console.log(body(paint.faint(` (${withAxes.length - 8} more)`)));
|
|
470
|
+
}
|
|
471
|
+
const crossed = reconcile(defined, used);
|
|
472
|
+
const dead = crossed.filter((r) => r.deadOptions.length > 0);
|
|
473
|
+
const stray = crossed.filter((r) => r.undeclared.length > 0);
|
|
474
|
+
const orphans = crossed.filter((r) => r.orphan);
|
|
475
|
+
if (dead.length === 0 && stray.length === 0 && orphans.length === 0)
|
|
476
|
+
return;
|
|
477
|
+
console.log("");
|
|
478
|
+
console.log(section("What the two readings disagree about"));
|
|
479
|
+
for (const r of stray.slice(0, 4)) {
|
|
480
|
+
for (const u of r.undeclared) {
|
|
481
|
+
console.log(body(` ${paint.strong(r.name)} is passed ${u.axis}="${u.values.join('" | "')}" ${paint.faint("- its own type does not offer that")}`));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const r of dead.slice(0, 4)) {
|
|
485
|
+
for (const d of r.deadOptions) {
|
|
486
|
+
console.log(body(` ${paint.strong(r.name)} declares ${d.axis}="${d.options.join('" | "')}" ${paint.faint("- and nothing ever passes it")}`));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (orphans.length > 0) {
|
|
490
|
+
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.`)));
|
|
491
|
+
}
|
|
411
492
|
}
|
|
412
493
|
/**
|
|
413
494
|
* Laws their code already obeys, printed with the evidence that found them.
|
|
@@ -455,7 +536,7 @@ function printCrosswalk(mine) {
|
|
|
455
536
|
return;
|
|
456
537
|
console.log(body(paint.strong(title)));
|
|
457
538
|
for (const r of list.slice(0, limit)) {
|
|
458
|
-
console.log(body(` ${r.component.name.padEnd(18)} ${paint.faint(`${r.component.files} files`)} ${paint.dim(r.because)}`));
|
|
539
|
+
console.log(body(` ${r.component.name.padEnd(18)} ${paint.faint(r.component.files === 0 ? "declared" : `${r.component.files} files`)} ${paint.dim(r.because)}`));
|
|
459
540
|
}
|
|
460
541
|
if (list.length > limit) {
|
|
461
542
|
console.log(body(paint.faint(` (${list.length - limit} more)`)));
|
|
@@ -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