synthesisui 0.16.79 → 0.16.81
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/anatomy-read.js +15 -1
- package/dist/commands/import.js +338 -16
- package/dist/commands/mcp.js +9 -0
- package/dist/component-codegen.js +11 -0
- package/dist/doctor/architecture.js +134 -0
- package/dist/doctor/call-sites.js +171 -0
- package/dist/doctor/component-gate.js +235 -0
- package/dist/doctor/components-scan.js +70 -2
- package/dist/doctor/crosswalk.js +59 -2
- package/dist/doctor/definitions-scan.js +32 -0
- package/dist/doctor/transcribe.js +116 -0
- package/dist/doctor/variant-read.js +584 -0
- package/dist/index.js +35 -5
- package/dist/progress.js +71 -0
- package/dist/skill-import.js +200 -11
- package/package.json +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW THIS PROJECT IS ORGANISED - measured, chosen, and then true in every prompt.
|
|
3
|
+
*
|
|
4
|
+
* "The architecture of the project should live in a general rule as it is structured,
|
|
5
|
+
* because then in every prompt that architecture and the way the project was structured
|
|
6
|
+
* will be respected - and this can be analysed in the skill with the user selecting the
|
|
7
|
+
* correct architecture (a project can have more than one folder architecture, so we can
|
|
8
|
+
* just select which one has priority)" (dono, 01/08).
|
|
9
|
+
*
|
|
10
|
+
* It is the most underrated line in that message. Everything else in this pipeline
|
|
11
|
+
* describes what a component LOOKS like; this describes where the next one GOES. An
|
|
12
|
+
* agent that knows the palette and not the tree writes a correct component in the wrong
|
|
13
|
+
* place, and a person has to move it every time.
|
|
14
|
+
*
|
|
15
|
+
* MEASURED, NOT ASKED FIRST. The shapes below are counted off their real directories,
|
|
16
|
+
* ranked by how many components sit in each. A project genuinely has more than one -
|
|
17
|
+
* a library organised atomically inside a monorepo whose apps are organised by feature -
|
|
18
|
+
* so this returns all of them and the person picks which one has PRIORITY. That word is
|
|
19
|
+
* theirs and it is the right one: the others are still true, they just do not decide.
|
|
20
|
+
*/
|
|
21
|
+
const SHAPES = [
|
|
22
|
+
{
|
|
23
|
+
kind: "atomic",
|
|
24
|
+
needs: [
|
|
25
|
+
["atoms", "atom"],
|
|
26
|
+
["molecules", "molecule"],
|
|
27
|
+
["organisms", "organism"],
|
|
28
|
+
],
|
|
29
|
+
describe: (a) => `Components live in an atomic ladder under \`${a.root}\` - ${a.evidence.join("/")}. A new component goes on the rung that matches what it is made of: an atom composes nothing of yours, a molecule composes atoms, an organism is a whole region of a screen. Put it on the wrong rung and the ladder stops being true.`,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
kind: "feature",
|
|
33
|
+
needs: [["features", "modules", "domains"]],
|
|
34
|
+
describe: (a) => `Code is organised BY FEATURE under \`${a.root}\`. A new component belongs to the feature that uses it, beside its own logic, and only moves up to the shared layer when a second feature needs it. Reaching across features is the thing this shape exists to prevent.`,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
kind: "layered",
|
|
38
|
+
needs: [
|
|
39
|
+
["ui", "components"],
|
|
40
|
+
["hooks", "lib", "utils"],
|
|
41
|
+
],
|
|
42
|
+
describe: (a) => `Code is split by LAYER under \`${a.root}\` - ${a.evidence.join(", ")}. A component goes in the ui layer and nothing else does; state and helpers stay in theirs. A component reaching into another layer is fine, a helper reaching back into ui is not.`,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
kind: "colocated",
|
|
46
|
+
needs: [["app", "routes", "pages"]],
|
|
47
|
+
describe: (a) => `Components sit BESIDE THE ROUTE that uses them, under \`${a.root}\`. A new one starts local to its page and only moves to the shared folder when a second page needs it - which keeps the shared folder meaning "shared" rather than "everything".`,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
kind: "flat",
|
|
51
|
+
needs: [["components"]],
|
|
52
|
+
describe: (a) => `Every component sits directly under \`${a.root}\`, one folder deep. It is the shape that stays legible longest while a system is small, and the one to revisit when the folder passes about forty names.`,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Which shapes this tree actually has, ranked by how much code each holds.
|
|
57
|
+
*
|
|
58
|
+
* `flat` is checked last and only reported when nothing more specific matched under the
|
|
59
|
+
* same root: a `components/` folder inside an atomic library is not a second
|
|
60
|
+
* architecture, it is the same one seen from further away.
|
|
61
|
+
*/
|
|
62
|
+
export function detectArchitectures(entries) {
|
|
63
|
+
const found = [];
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const lower = new Map(entry.dirs.map((d) => [d.toLowerCase(), d]));
|
|
66
|
+
for (const shape of SHAPES) {
|
|
67
|
+
const evidence = [];
|
|
68
|
+
let complete = true;
|
|
69
|
+
for (const alternatives of shape.needs) {
|
|
70
|
+
const hit = alternatives.map((a) => lower.get(a)).find(Boolean);
|
|
71
|
+
if (!hit) {
|
|
72
|
+
complete = false;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
evidence.push(hit);
|
|
76
|
+
}
|
|
77
|
+
if (!complete)
|
|
78
|
+
continue;
|
|
79
|
+
if (shape.kind === "flat" &&
|
|
80
|
+
found.some((f) => f.root === entry.path && f.kind !== "flat")) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
found.push({
|
|
84
|
+
kind: shape.kind,
|
|
85
|
+
root: entry.path,
|
|
86
|
+
files: entry.files,
|
|
87
|
+
evidence,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return found
|
|
92
|
+
.filter((a) => a.kind !== "flat" ||
|
|
93
|
+
!found.some((b) => b.root === a.root && b.kind !== "flat"))
|
|
94
|
+
.sort((a, b) => b.files - a.files || a.root.localeCompare(b.root));
|
|
95
|
+
}
|
|
96
|
+
/** The sentence that becomes the rule. Written for an agent about to add a file. */
|
|
97
|
+
export function describeArchitecture(a) {
|
|
98
|
+
const shape = SHAPES.find((s) => s.kind === a.kind);
|
|
99
|
+
return shape ? shape.describe(a) : `Components live under \`${a.root}\`.`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The rule itself, in the shape `reading.rules` holds.
|
|
103
|
+
*
|
|
104
|
+
* `applies` is EMPTY on purpose - this governs the system rather than a component, so
|
|
105
|
+
* it reaches CLAUDE.md and travels into every prompt instead of waiting for somebody to
|
|
106
|
+
* open one component. And `fact: true`, because it was read off their directories: it is
|
|
107
|
+
* true once, not a habit that needs a third sighting.
|
|
108
|
+
*/
|
|
109
|
+
export function architectureRule(a) {
|
|
110
|
+
return {
|
|
111
|
+
text: describeArchitecture(a),
|
|
112
|
+
applies: [],
|
|
113
|
+
kind: "implementation",
|
|
114
|
+
fact: true,
|
|
115
|
+
evidence: `${a.evidence.join("/")} under ${a.root}, holding ${a.files} component file${a.files === 1 ? "" : "s"}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* One line naming what was found, and the choice it leaves open.
|
|
120
|
+
*
|
|
121
|
+
* A project with two shapes is not a project with a mistake. Saying so is what keeps
|
|
122
|
+
* the question from reading as an accusation.
|
|
123
|
+
*/
|
|
124
|
+
export function describeChoice(found) {
|
|
125
|
+
if (found.length === 0)
|
|
126
|
+
return null;
|
|
127
|
+
if (found.length === 1) {
|
|
128
|
+
return `One shape, read off your directories: ${found[0].kind} under \`${found[0].root}\`.`;
|
|
129
|
+
}
|
|
130
|
+
const list = found
|
|
131
|
+
.map((a) => `${a.kind} under \`${a.root}\` (${a.files} files)`)
|
|
132
|
+
.join(", ");
|
|
133
|
+
return `${found.length} shapes, and none of them is a mistake: ${list}. A library organised atomically inside a monorepo whose apps are organised by feature is two true answers. Pick which one has PRIORITY - it becomes the rule every prompt reads, and the others stay true without deciding.`;
|
|
134
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW A COMPONENT IS ACTUALLY USED - the owner's step five, and the one nothing read.
|
|
3
|
+
*
|
|
4
|
+
* "For each component on the page understand how it is used, first the rules, what the
|
|
5
|
+
* component's recipe is, how many sub-components it has, which properties it is used
|
|
6
|
+
* with, does it have ...props? which does it make explicit? which does it use? that
|
|
7
|
+
* would be good to have in the component's usage/recipes" (dono, 01/08).
|
|
8
|
+
*
|
|
9
|
+
* Every one of those is answerable deterministically and none of it was being asked.
|
|
10
|
+
* Two readings already exist - what a component DECLARES and which literal values its
|
|
11
|
+
* props TAKE - and between them sits the question a person actually has when they are
|
|
12
|
+
* about to compose something: what do people pass it, what does it pass on, and what
|
|
13
|
+
* shows up beside it.
|
|
14
|
+
*
|
|
15
|
+
* THREE THINGS THIS ADDS
|
|
16
|
+
*
|
|
17
|
+
* forwards does the definition spread the rest of its props onto its element?
|
|
18
|
+
* If it does, anything you pass reaches the DOM - which is the answer
|
|
19
|
+
* to "can I put an aria-label on it" and it is never in a type.
|
|
20
|
+
* explicit which props the definition names and destructures. A prop that is
|
|
21
|
+
* named is a prop the author thought about; the rest is pass-through.
|
|
22
|
+
* together which components appear INSIDE this one at its call sites. A `Card`
|
|
23
|
+
* used with a `CardHeader` in twelve files is a pair, and that pair is
|
|
24
|
+
* a law nobody wrote down.
|
|
25
|
+
*
|
|
26
|
+
* The pairs come from USAGE rather than from the definition on purpose. A definition
|
|
27
|
+
* says what a component is made of; a call site says what people put in it, and those
|
|
28
|
+
* are different facts. A library composes nothing internally, so without this the
|
|
29
|
+
* composition half of a library's story is empty by construction.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* `function Name({ a, b, ...rest })`, `const Name = ({ a, …}) =>`, and
|
|
33
|
+
* `const Name = function ({ … })`.
|
|
34
|
+
*
|
|
35
|
+
* The arrow and the function expression share one shape - a parameter list opening with
|
|
36
|
+
* a destructure - so matching that is enough and no `=>` needs to appear. A first
|
|
37
|
+
* version tried to consume the arrow, and `([^)]*)` swallowed the destructure it was
|
|
38
|
+
* trying to reach, so every arrow component read as declaring no props (spec, 01/08).
|
|
39
|
+
*/
|
|
40
|
+
const DESTRUCTURED = /(?:function\s+([A-Z][A-Za-z0-9_]*)\s*\(\s*\{([^}]*)\}|(?:const|let)\s+([A-Z][A-Za-z0-9_]*)\s*(?::[^=]*)?=\s*(?:function\s*)?\(\s*\{([^}]*)\})/g;
|
|
41
|
+
/** `{...rest}` / `{...props}` on a JSX element - the forwarding, in the markup. */
|
|
42
|
+
const SPREAD_ONTO = /\{\s*\.\.\.\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
43
|
+
/**
|
|
44
|
+
* What a definition says about its own props.
|
|
45
|
+
*
|
|
46
|
+
* Both halves have to be true for `forwards`: a definition can collect a rest parameter
|
|
47
|
+
* and never spread it, which means the opposite of forwarding - it is deliberately
|
|
48
|
+
* swallowing what it was given.
|
|
49
|
+
*/
|
|
50
|
+
export function readDefinitionProps(name, source) {
|
|
51
|
+
const explicit = [];
|
|
52
|
+
let restName = null;
|
|
53
|
+
DESTRUCTURED.lastIndex = 0;
|
|
54
|
+
for (const m of source.matchAll(DESTRUCTURED)) {
|
|
55
|
+
const found = m[1] ?? m[3];
|
|
56
|
+
if (found !== name)
|
|
57
|
+
continue;
|
|
58
|
+
const body = m[2] ?? m[4] ?? "";
|
|
59
|
+
for (const raw of body.split(",")) {
|
|
60
|
+
const part = raw.trim();
|
|
61
|
+
if (!part)
|
|
62
|
+
continue;
|
|
63
|
+
if (part.startsWith("...")) {
|
|
64
|
+
restName = part.slice(3).trim() || null;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// `as: Component = "p"` names the prop `as`; the local alias is not the prop.
|
|
68
|
+
const prop = part.split(/[:=]/)[0].trim();
|
|
69
|
+
if (/^[a-zA-Z_$][\w$]*$/.test(prop) && !explicit.includes(prop)) {
|
|
70
|
+
explicit.push(prop);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
let forwards = null;
|
|
76
|
+
if (restName) {
|
|
77
|
+
SPREAD_ONTO.lastIndex = 0;
|
|
78
|
+
for (const m of source.matchAll(SPREAD_ONTO)) {
|
|
79
|
+
if (m[1] === restName) {
|
|
80
|
+
forwards = restName;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { explicit, forwards };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* WHICH COMPONENTS APPEAR INSIDE THIS ONE, at its call sites.
|
|
89
|
+
*
|
|
90
|
+
* Depth one only, and that is the point: `<Card><CardHeader/><CardBody/></Card>` says
|
|
91
|
+
* Card is used with those two. What is inside CardHeader is CardHeader's own story, and
|
|
92
|
+
* following it further would turn one page into a claim about the whole tree.
|
|
93
|
+
*
|
|
94
|
+
* Counted per FILE rather than per occurrence, because "twelve files agree" is a claim
|
|
95
|
+
* about a habit and "twelve occurrences" can be one loop.
|
|
96
|
+
*/
|
|
97
|
+
export function readNesting(file, source, into = new Map()) {
|
|
98
|
+
const open = /<([A-Z][A-Za-z0-9_]*(?:\.[A-Z][A-Za-z0-9_]*)*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/gs;
|
|
99
|
+
const stack = [];
|
|
100
|
+
open.lastIndex = 0;
|
|
101
|
+
for (const m of source.matchAll(open)) {
|
|
102
|
+
const name = m[1];
|
|
103
|
+
const selfClosing = m[3] === "/";
|
|
104
|
+
const parent = stack[stack.length - 1];
|
|
105
|
+
if (parent && parent !== name) {
|
|
106
|
+
const children = into.get(parent) ?? new Map();
|
|
107
|
+
const files = children.get(name) ?? new Set();
|
|
108
|
+
files.add(file);
|
|
109
|
+
children.set(name, files);
|
|
110
|
+
into.set(parent, children);
|
|
111
|
+
}
|
|
112
|
+
if (!selfClosing)
|
|
113
|
+
stack.push(name);
|
|
114
|
+
// A closing tag is not matched by the pattern above, so the stack is trimmed by
|
|
115
|
+
// the next opening tag's own nesting rather than by tracking every `</x>`. It
|
|
116
|
+
// over-nests in a flat list of siblings, which is why depth is capped at one and
|
|
117
|
+
// the pair only counts once per file.
|
|
118
|
+
if (stack.length > 8)
|
|
119
|
+
stack.shift();
|
|
120
|
+
}
|
|
121
|
+
return into;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The composition pairs worth calling a law, and the sentence each becomes.
|
|
125
|
+
*
|
|
126
|
+
* `minFiles` is the ladder's own habit threshold - three files that agree - because
|
|
127
|
+
* that is what separates a pattern from a coincidence, and it is the same number every
|
|
128
|
+
* other observed rule uses.
|
|
129
|
+
*/
|
|
130
|
+
export function nestingRules(nesting, own, minFiles = 3) {
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const [parent, children] of nesting) {
|
|
133
|
+
if (!own.has(parent))
|
|
134
|
+
continue;
|
|
135
|
+
for (const [child, files] of children) {
|
|
136
|
+
if (!own.has(child) || files.size < minFiles)
|
|
137
|
+
continue;
|
|
138
|
+
out.push({
|
|
139
|
+
component: parent,
|
|
140
|
+
child,
|
|
141
|
+
files: files.size,
|
|
142
|
+
text: `${parent} is used with ${child} - ${files.size} files put one inside the other, so keep the pair rather than reaching for something else inside it.`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out.sort((a, b) => b.files - a.files);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The two prop sentences, for `usage`.
|
|
150
|
+
*
|
|
151
|
+
* `forwards` is the one an agent needs and cannot get from a type: it is the difference
|
|
152
|
+
* between "this component accepts what I pass" and "this component ignores it". Stated
|
|
153
|
+
* as a permission rather than as a fact about the code, because that is how it will be
|
|
154
|
+
* read.
|
|
155
|
+
*/
|
|
156
|
+
export function propRules(name, read) {
|
|
157
|
+
const out = [];
|
|
158
|
+
if (read.forwards) {
|
|
159
|
+
out.push({
|
|
160
|
+
text: `${name} forwards the rest of its props to the element it renders, so anything you pass that it does not name - an id, an aria-label, a data attribute, a ref - reaches the DOM.`,
|
|
161
|
+
fact: true,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else if (read.explicit.length > 0) {
|
|
165
|
+
out.push({
|
|
166
|
+
text: `${name} names its props and forwards nothing else, so a prop it does not declare is dropped rather than passed through. It takes: ${read.explicit.slice(0, 12).join(", ")}.`,
|
|
167
|
+
fact: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS THIS A COMPONENT OF A DESIGN SYSTEM?
|
|
3
|
+
*
|
|
4
|
+
* Nothing asked. The definition reader matched `export const X` / `export function X`
|
|
5
|
+
* for any capitalised name, and pointed at a whole monorepo it found **704
|
|
6
|
+
* components in a repo that has 37** (dono, 01/08). The other 667 were:
|
|
7
|
+
*
|
|
8
|
+
* ds-activation-page a route
|
|
9
|
+
* ds-auth-layout a Next layout
|
|
10
|
+
* ds-cache-providers a provider
|
|
11
|
+
* ds-approvals-monitoring-template
|
|
12
|
+
*
|
|
13
|
+
* A page is not a component of a design system, and it has no visual identity to
|
|
14
|
+
* draw - which is why the vitrine came back unreadable. The renderer was not the
|
|
15
|
+
* problem; 95% of what entered had nothing to render.
|
|
16
|
+
*
|
|
17
|
+
* WHAT THIS IS NOT. It is not a quality bar and it never asks whether something is
|
|
18
|
+
* good. Every test here is about CATEGORY: a route, a provider, a config object.
|
|
19
|
+
* And nothing disappears - every rejection carries a reason, and the import prints
|
|
20
|
+
* them grouped, because "649 seen, not imported, and here is why" is information and
|
|
21
|
+
* a silent drop is a lie.
|
|
22
|
+
*
|
|
23
|
+
* FEWER AND BETTER. 37 components that all draw is a transformative result; 704
|
|
24
|
+
* where 30 draw is a broken product. This is the pipeline choosing legibility over
|
|
25
|
+
* coverage.
|
|
26
|
+
*/
|
|
27
|
+
import { localOnlyNames } from "./components-scan.js";
|
|
28
|
+
/**
|
|
29
|
+
* Next's own reserved file names. Exact, not heuristic: the framework defines these,
|
|
30
|
+
* so a file called `page.tsx` IS a route in every Next codebase there is.
|
|
31
|
+
*/
|
|
32
|
+
const RESERVED_FILE = /(^|[/\\])(page|layout|template|default|error|global-error|not-found|loading|route|middleware|instrumentation|sitemap|robots|opengraph-image|icon|apple-icon|manifest)\.[jt]sx?$/;
|
|
33
|
+
/** Nuxt, SvelteKit and Remix equivalents, for the same exact reason. */
|
|
34
|
+
const RESERVED_FILE_OTHER = /(^|[/\\])(\+page|\+layout|\+server|_app|_document|_layout)\.[a-z]+$/;
|
|
35
|
+
/**
|
|
36
|
+
* A name that means WHOLE SCREEN in every codebase.
|
|
37
|
+
*
|
|
38
|
+
* Suffix-decisive, and deliberately short. `Page`, `Screen`, `Route` and `Template`
|
|
39
|
+
* name a destination; `Layout` names the frame around one. A component library does
|
|
40
|
+
* not export those, and the 704-component run was mostly these four words.
|
|
41
|
+
*
|
|
42
|
+
* `View` is NOT here on purpose: `TreeView` and `ListView` are components in a
|
|
43
|
+
* hundred libraries. It only counts when the file itself is a route.
|
|
44
|
+
*/
|
|
45
|
+
const SCREEN_SUFFIX = /(Page|Screen|Route|Template|Layout)$/;
|
|
46
|
+
/** Something that carries context down the tree rather than drawing. */
|
|
47
|
+
const PROVIDER_SUFFIX = /(Provider|Providers|Context|Boundary|Guard|Wrapper)$/;
|
|
48
|
+
/** Any JSX at all in the file. A capitalised export with none is a constant, a
|
|
49
|
+
* config object or a helper - `export const ROUTES = {…}` is not a component. */
|
|
50
|
+
const HAS_JSX = /<[A-Za-z][^>]*>|<>/;
|
|
51
|
+
/**
|
|
52
|
+
* Any sign that this file decides how something LOOKS.
|
|
53
|
+
*
|
|
54
|
+
* The generic catch, and the one that gets the providers whose names give nothing
|
|
55
|
+
* away. A file with no class, no style, no styled-anything and no variant helper is
|
|
56
|
+
* plumbing: it may render, but every pixel it produces was decided somewhere else.
|
|
57
|
+
*/
|
|
58
|
+
const HAS_STYLING = /className|class=|style=|styled[.(]|css`|cva\(|\btv\(|makeStyles|sx=|tw`/;
|
|
59
|
+
/** `createContext` + a `.Provider` in the return is a provider whatever it is
|
|
60
|
+
* called - `RootWrapper` and `CommonProviders` both read this way. */
|
|
61
|
+
const IS_CONTEXT = /createContext\s*[<(]/;
|
|
62
|
+
/** A lowercase JSX tag: an html element this file renders ITSELF. */
|
|
63
|
+
const OWN_ELEMENT = /<(?:[a-z][a-z0-9]*)(\s|>|\/)/;
|
|
64
|
+
/** Every capitalised JSX tag the file opens, dots included. */
|
|
65
|
+
const RENDERED_TAG = /<([A-Z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)/g;
|
|
66
|
+
/** `import … from "some-package"` - not relative, not an alias of theirs. */
|
|
67
|
+
const THIRD_PARTY_IMPORT = /import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["'](?![./]|@\/)([^"']+)["']/g;
|
|
68
|
+
/**
|
|
69
|
+
* A file whose every pixel belongs to somebody else's component.
|
|
70
|
+
*
|
|
71
|
+
* `apps/web-dashboard/src/components/ui/_legacy/_chakra-ui/accordion.tsx` forwards
|
|
72
|
+
* to `<Accordion.ItemTrigger>` and renders no element of its own; 205 files like it
|
|
73
|
+
* arrived as components of the system (dono, 01/08). The first version rejected them
|
|
74
|
+
* for "deciding nothing about how anything looks", which reached the right outcome
|
|
75
|
+
* with a false reason - `gap="4"` and `rotate={…}` ARE styling decisions, just not
|
|
76
|
+
* through a class.
|
|
77
|
+
*
|
|
78
|
+
* The truthful category is the one this pipeline already has a word for: EXTERNAL.
|
|
79
|
+
* A component that renders only a third party's components, with no element of its
|
|
80
|
+
* own, is an ADAPTER for that library. Its markup and its pixels are that library's,
|
|
81
|
+
* and a design system that claimed them would be claiming what it cannot re-theme.
|
|
82
|
+
*
|
|
83
|
+
* The test needs no list of style props, which is why it holds across Chakra, Panda,
|
|
84
|
+
* MUI and whatever comes next: no html element of its own AND it imports components
|
|
85
|
+
* from a package.
|
|
86
|
+
*/
|
|
87
|
+
function adapterFor(source, internal) {
|
|
88
|
+
// It renders an html element of its own: whatever else it does, some pixel here is
|
|
89
|
+
// this file's decision.
|
|
90
|
+
if (OWN_ELEMENT.test(source))
|
|
91
|
+
return null;
|
|
92
|
+
/**
|
|
93
|
+
* THE POLYMORPHIC ESCAPE, and it is not a detail.
|
|
94
|
+
*
|
|
95
|
+
* `function Text({ as: Component = "p" })` renders `<Component>` and no lowercase
|
|
96
|
+
* tag, so the first version of this test rejected the real `Text` and `Pill` of a
|
|
97
|
+
* real library as adapters (spec, 01/08). `Component` is a prop rename - a name the
|
|
98
|
+
* file BINDS - and a tag a file binds itself is not somebody else's component.
|
|
99
|
+
*
|
|
100
|
+
* The reachability reader already answers exactly this question, so it answers it
|
|
101
|
+
* here rather than being approximated a second time.
|
|
102
|
+
*/
|
|
103
|
+
const own = localOnlyNames(source);
|
|
104
|
+
const fromPackage = new Map();
|
|
105
|
+
THIRD_PARTY_IMPORT.lastIndex = 0;
|
|
106
|
+
for (const m of source.matchAll(THIRD_PARTY_IMPORT)) {
|
|
107
|
+
const spec = m[2];
|
|
108
|
+
if (internal.some((pre) => spec === pre || spec.startsWith(`${pre}/`))) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
for (const raw of m[1].replace(/[{}]/g, ",").split(",")) {
|
|
112
|
+
const local = (raw
|
|
113
|
+
.trim()
|
|
114
|
+
.split(/\s+as\s+/)
|
|
115
|
+
.pop() ?? "").trim();
|
|
116
|
+
if (/^[A-Z]/.test(local))
|
|
117
|
+
fromPackage.set(local, spec);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (fromPackage.size === 0)
|
|
121
|
+
return null;
|
|
122
|
+
RENDERED_TAG.lastIndex = 0;
|
|
123
|
+
let theirs = null;
|
|
124
|
+
let seen = 0;
|
|
125
|
+
for (const m of source.matchAll(RENDERED_TAG)) {
|
|
126
|
+
const root = m[1].split(".")[0];
|
|
127
|
+
if (own.has(root))
|
|
128
|
+
return null;
|
|
129
|
+
const spec = fromPackage.get(root);
|
|
130
|
+
// A tag from neither - their own component, imported relatively - means this file
|
|
131
|
+
// composes the system rather than adapting a library.
|
|
132
|
+
if (!spec)
|
|
133
|
+
return null;
|
|
134
|
+
theirs ??= spec;
|
|
135
|
+
seen += 1;
|
|
136
|
+
}
|
|
137
|
+
return seen > 0 ? theirs : null;
|
|
138
|
+
}
|
|
139
|
+
export function gateComponent(input) {
|
|
140
|
+
const { name, file, source } = input;
|
|
141
|
+
if (RESERVED_FILE.test(file) || RESERVED_FILE_OTHER.test(file)) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
why: "route",
|
|
145
|
+
because: `\`${file}\` is a file name the framework reserves - it is a route, not a component somebody can import`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (SCREEN_SUFFIX.test(name)) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
why: "screen",
|
|
152
|
+
because: `\`${name}\` names a whole screen or the frame around one. A design system is made of the pieces a screen is built FROM`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (PROVIDER_SUFFIX.test(name) || IS_CONTEXT.test(source)) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
why: "provider",
|
|
159
|
+
because: `\`${name}\` carries context or wraps a tree rather than drawing anything of its own`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const adapter = adapterFor(source, input.internal ?? []);
|
|
163
|
+
if (adapter) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
why: "adapter",
|
|
167
|
+
because: `\`${name}\` renders \`${adapter}\`'s components and no element of its own - its markup and every pixel are that library's, so there is nothing here for this system to re-theme`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (!HAS_JSX.test(source)) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
why: "config",
|
|
174
|
+
because: `\`${name}\` is a capitalised export in a file with no markup - a constant, a config object or a helper`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (!HAS_STYLING.test(source)) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
why: "no-styling",
|
|
181
|
+
because: `\`${name}\` renders but decides nothing about how anything looks - every pixel it produces was decided somewhere else`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { ok: true };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The rejections, grouped and counted, in the order that reads best out loud.
|
|
188
|
+
*
|
|
189
|
+
* NO SILENT CAPS. A run that quietly imports 40 of 704 reads as "your project has 40
|
|
190
|
+
* components", which is the one thing a census must never imply. Every name is
|
|
191
|
+
* carried; the printer shows the first few per group and says how many more.
|
|
192
|
+
*/
|
|
193
|
+
export const SKIP_LABEL = {
|
|
194
|
+
route: "routes and framework files",
|
|
195
|
+
screen: "whole screens, and the frames around them",
|
|
196
|
+
provider: "providers, contexts and wrappers",
|
|
197
|
+
adapter: "adapters for another library's components",
|
|
198
|
+
config: "constants, config and helpers",
|
|
199
|
+
"no-jsx": "capitalised exports with no markup",
|
|
200
|
+
"no-styling": "components that decide nothing about how anything looks",
|
|
201
|
+
};
|
|
202
|
+
export function groupSkips(skips) {
|
|
203
|
+
const order = [
|
|
204
|
+
"screen",
|
|
205
|
+
"route",
|
|
206
|
+
"provider",
|
|
207
|
+
"adapter",
|
|
208
|
+
"no-styling",
|
|
209
|
+
"config",
|
|
210
|
+
"no-jsx",
|
|
211
|
+
];
|
|
212
|
+
const by = new Map();
|
|
213
|
+
for (const s of skips) {
|
|
214
|
+
by.set(s.why, [...(by.get(s.why) ?? []), s.name]);
|
|
215
|
+
}
|
|
216
|
+
return order
|
|
217
|
+
.filter((why) => (by.get(why) ?? []).length > 0)
|
|
218
|
+
.map((why) => ({
|
|
219
|
+
why,
|
|
220
|
+
label: SKIP_LABEL[why],
|
|
221
|
+
names: (by.get(why) ?? []).sort(),
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* One line saying what the gate did, in the terms the decision was actually made on.
|
|
226
|
+
*
|
|
227
|
+
* The sentence a person needs in order to trust a number that just dropped by an
|
|
228
|
+
* order of magnitude - and to notice if it dropped too far.
|
|
229
|
+
*/
|
|
230
|
+
export function describeGate(kept, skipped) {
|
|
231
|
+
if (skipped === 0) {
|
|
232
|
+
return `${kept} component${kept === 1 ? "" : "s"} - every capitalised export here is one.`;
|
|
233
|
+
}
|
|
234
|
+
return `${kept} component${kept === 1 ? "" : "s"}, and ${skipped} export${skipped === 1 ? "" : "s"} left out. A page is not a component of a design system: it has no visual identity to draw, and importing one puts a name in your vitrine that nobody can compose with. Everything left out is listed below with the reason, so you can disagree with any of it.`;
|
|
235
|
+
}
|
|
@@ -13,17 +13,30 @@ const LITERAL_PROP = /([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*\{?\s*["']([^"']*)["']\s*\}
|
|
|
13
13
|
/** `dense` with no value is a boolean prop set to true, and that IS a literal
|
|
14
14
|
* decision worth counting. */
|
|
15
15
|
const BARE_PROP = /(^|\s)([a-z][a-zA-Z0-9_]*)(?=\s|$)/g;
|
|
16
|
+
/**
|
|
17
|
+
* A KEYWORD IS NOT AN IDENTIFIER, and the difference decides whether a `<` opens
|
|
18
|
+
* an element. `return <Card>one</Card>` is a component; `useState<Foo>()` is a
|
|
19
|
+
* type argument. Both are preceded by a word.
|
|
20
|
+
*
|
|
21
|
+
* Prettier writes `return <Foo />;` on one line whenever it fits, so without this
|
|
22
|
+
* every short component written that way read as a generic and vanished from the
|
|
23
|
+
* inventory - which is how a spec for something else caught it.
|
|
24
|
+
*/
|
|
25
|
+
const JSX_BEFORE = /\b(return|case|default|await|yield|typeof|in|of|do|else|new)$/;
|
|
16
26
|
/**
|
|
17
27
|
* A generic in type position (`useState<Foo>()`, `Array<Item>`) is not an
|
|
18
28
|
* element. The tell is what precedes the `<`: an identifier or a closing paren
|
|
19
|
-
* means a type argument, while JSX follows `(`, `{`, `>`, a newline or
|
|
29
|
+
* means a type argument, while JSX follows `(`, `{`, `>`, a newline, a keyword or
|
|
30
|
+
* nothing.
|
|
20
31
|
*/
|
|
21
32
|
function looksLikeType(source, at) {
|
|
22
33
|
for (let i = at - 1; i >= 0; i--) {
|
|
23
34
|
const c = source[i];
|
|
24
35
|
if (c === " " || c === "\t")
|
|
25
36
|
continue;
|
|
26
|
-
|
|
37
|
+
if (!/[A-Za-z0-9_)\]]/.test(c))
|
|
38
|
+
return false;
|
|
39
|
+
return !JSX_BEFORE.test(source.slice(Math.max(0, i - 11), i + 1));
|
|
27
40
|
}
|
|
28
41
|
return false;
|
|
29
42
|
}
|
|
@@ -81,6 +94,56 @@ function importedNames(source, internal = []) {
|
|
|
81
94
|
}
|
|
82
95
|
return out;
|
|
83
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Capitalised names a file BINDS ITSELF and does not export.
|
|
99
|
+
*
|
|
100
|
+
* `<Component>` and `<CustomLegend/>` both arrived in a real library's inventory as
|
|
101
|
+
* components of the system (dono, 01/08). Neither is one:
|
|
102
|
+
*
|
|
103
|
+
* function Text({ as: Component = "p" }) // React's polymorphic idiom -
|
|
104
|
+
* // `Component` is a prop rename
|
|
105
|
+
* const CustomLegend = () => (…) // a closure inside LineChart
|
|
106
|
+
*
|
|
107
|
+
* Capitalisation is React's rule for "not an html tag", which is not the same
|
|
108
|
+
* question as "is this a component the system offers". The test that separates them
|
|
109
|
+
* is REACHABILITY: a component of the system is either imported from somewhere or
|
|
110
|
+
* exported to somewhere. A name that only exists inside one function body is that
|
|
111
|
+
* function's private wiring, and giving it a recipe invents a component nobody can
|
|
112
|
+
* import.
|
|
113
|
+
*
|
|
114
|
+
* Local AND exported stays, of course - that is just a component defined where it
|
|
115
|
+
* is used.
|
|
116
|
+
*/
|
|
117
|
+
const BOUND = /(?:const|let|var|function|class)\s+([A-Z][A-Za-z0-9_]*)|[{,]\s*\w+\s*:\s*([A-Z][A-Za-z0-9_]*)\s*(?:=[^,}]*)?\s*[,}]/g;
|
|
118
|
+
const EXPORTED_NAME = /export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Z][A-Za-z0-9_]*)|export\s+default\s+([A-Z][A-Za-z0-9_]*)|export\s*\{([^}]*)\}/g;
|
|
119
|
+
export function localOnlyNames(source) {
|
|
120
|
+
const exported = new Set();
|
|
121
|
+
EXPORTED_NAME.lastIndex = 0;
|
|
122
|
+
for (const m of source.matchAll(EXPORTED_NAME)) {
|
|
123
|
+
if (m[1])
|
|
124
|
+
exported.add(m[1]);
|
|
125
|
+
if (m[2])
|
|
126
|
+
exported.add(m[2]);
|
|
127
|
+
if (m[3]) {
|
|
128
|
+
for (const raw of m[3].split(",")) {
|
|
129
|
+
const name = raw
|
|
130
|
+
.trim()
|
|
131
|
+
.split(/\s+as\s+/)[0]
|
|
132
|
+
?.trim();
|
|
133
|
+
if (name && /^[A-Z]/.test(name))
|
|
134
|
+
exported.add(name);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const out = new Set();
|
|
139
|
+
BOUND.lastIndex = 0;
|
|
140
|
+
for (const m of source.matchAll(BOUND)) {
|
|
141
|
+
const name = m[1] ?? m[2];
|
|
142
|
+
if (name && !exported.has(name))
|
|
143
|
+
out.add(name);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
84
147
|
export function emptyTally() {
|
|
85
148
|
return new Map();
|
|
86
149
|
}
|
|
@@ -92,11 +155,16 @@ internal = []) {
|
|
|
92
155
|
if (!/\.(tsx|jsx|vue|svelte)$/i.test(file))
|
|
93
156
|
return;
|
|
94
157
|
const owners = importedNames(source, internal);
|
|
158
|
+
const private_ = localOnlyNames(source);
|
|
95
159
|
ELEMENT.lastIndex = 0;
|
|
96
160
|
for (const m of source.matchAll(ELEMENT)) {
|
|
97
161
|
if (m.index != null && looksLikeType(source, m.index))
|
|
98
162
|
continue;
|
|
99
163
|
const name = m[1];
|
|
164
|
+
// Private wiring, not a component of the system. An import wins: a file may
|
|
165
|
+
// shadow nothing and still bind a name it also imports.
|
|
166
|
+
if (!owners.has(name) && private_.has(name.split(".")[0]))
|
|
167
|
+
continue;
|
|
100
168
|
const body = m[2] ?? "";
|
|
101
169
|
let hit = tally.get(name);
|
|
102
170
|
if (!hit) {
|