synthesisui 0.16.78 → 0.16.80
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 +63 -8
- package/dist/commands/component.js +38 -1
- package/dist/commands/import.js +211 -14
- package/dist/commands/mcp.js +128 -0
- package/dist/component-codegen.js +52 -15
- 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/index.js +35 -5
- package/dist/project-facts.js +58 -2
- package/dist/skill-import.js +215 -17
- package/package.json +1 -1
package/dist/anatomy-read.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* of the three outcomes this pipeline has paid for twice.
|
|
31
31
|
*/
|
|
32
32
|
import { transcribe, } from "./doctor/transcribe.js";
|
|
33
|
-
/** The
|
|
33
|
+
/** The ten forms, closed. A renderer that accepts any tag draws anything. */
|
|
34
34
|
const FORMS = new Set([
|
|
35
35
|
"image",
|
|
36
36
|
"heading",
|
|
@@ -42,6 +42,7 @@ const FORMS = new Set([
|
|
|
42
42
|
"stack",
|
|
43
43
|
"component",
|
|
44
44
|
"external",
|
|
45
|
+
"slot",
|
|
45
46
|
]);
|
|
46
47
|
/** The two that arrange, and the only two that take children. */
|
|
47
48
|
const CONTAINERS = new Set(["row", "stack"]);
|
|
@@ -95,7 +96,11 @@ function formOf(node) {
|
|
|
95
96
|
* `declared` is their own tokens, so a `bg-ocean-500` on a nested part resolves
|
|
96
97
|
* to the name they gave it rather than to a literal.
|
|
97
98
|
*/
|
|
98
|
-
export function resolveAnatomy(read, declared, deps
|
|
99
|
+
export function resolveAnatomy(read, declared, deps,
|
|
100
|
+
/** Their name → the slug it reaches in this system. See `RefResolver`. */
|
|
101
|
+
resolve,
|
|
102
|
+
/** What the component returns, when the skill said and it is not a plain tag. */
|
|
103
|
+
root) {
|
|
99
104
|
const parts = {};
|
|
100
105
|
const composes = [];
|
|
101
106
|
const external = [];
|
|
@@ -147,12 +152,22 @@ export function resolveAnatomy(read, declared, deps) {
|
|
|
147
152
|
// on one would be us claiming styles that belong to somebody else.
|
|
148
153
|
if (FRONTIERS.has(as)) {
|
|
149
154
|
if (as === "component") {
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
155
|
+
const name = String(node.ref ?? "").trim();
|
|
156
|
+
if (!name)
|
|
152
157
|
continue;
|
|
153
|
-
if (!composes.includes(
|
|
154
|
-
composes.push(
|
|
155
|
-
|
|
158
|
+
if (!composes.includes(name))
|
|
159
|
+
composes.push(name);
|
|
160
|
+
/**
|
|
161
|
+
* THE CROSSWALK DECIDES WHAT THIS REACHES.
|
|
162
|
+
*
|
|
163
|
+
* `Tag` is bucketed `nearly` with canonical `badge`; kebab-casing it
|
|
164
|
+
* looks for `ds-tag` and finds nothing. Without a resolver we keep the
|
|
165
|
+
* kebab, which is what a system generated by us would want anyway.
|
|
166
|
+
*/
|
|
167
|
+
const slug = resolve?.(name) ?? safePartName(name);
|
|
168
|
+
out.push(slug && slug !== safePartName(name)
|
|
169
|
+
? { as, ref: slug, refName: name }
|
|
170
|
+
: { as, ref: slug || safePartName(name) });
|
|
156
171
|
continue;
|
|
157
172
|
}
|
|
158
173
|
const from = String(node.from ?? "").trim();
|
|
@@ -184,6 +199,19 @@ export function resolveAnatomy(read, declared, deps) {
|
|
|
184
199
|
const text = typeof node.text === "string" ? node.text.trim() : "";
|
|
185
200
|
if (text)
|
|
186
201
|
node_.text = text.slice(0, 120);
|
|
202
|
+
/**
|
|
203
|
+
* A SLOT KEEPS ITS STYLES AND BORROWS ITS CONTENT.
|
|
204
|
+
*
|
|
205
|
+
* Unlike the other two frontiers it is not somebody else's element - it IS
|
|
206
|
+
* theirs, and its border and hover belong on it. Only what goes inside is the
|
|
207
|
+
* caller's, so `expects` says what that is in their words instead of the
|
|
208
|
+
* preview drawing a blank.
|
|
209
|
+
*/
|
|
210
|
+
if (as === "slot") {
|
|
211
|
+
const expects = typeof node.expects === "string" ? node.expects.trim() : "";
|
|
212
|
+
if (expects)
|
|
213
|
+
node_.expects = expects.slice(0, 80);
|
|
214
|
+
}
|
|
187
215
|
// Only the two arrangers descend. A leaf with children is a leaf that was
|
|
188
216
|
// labelled wrongly, and its children would render nowhere.
|
|
189
217
|
if (CONTAINERS.has(as) && Array.isArray(node.children)) {
|
|
@@ -202,13 +230,40 @@ export function resolveAnatomy(read, declared, deps) {
|
|
|
202
230
|
return out;
|
|
203
231
|
};
|
|
204
232
|
const tree = walk(top, 0);
|
|
233
|
+
/**
|
|
234
|
+
* THE ROOT, RESOLVED THE SAME WAY.
|
|
235
|
+
*
|
|
236
|
+
* A dotted capitalised name is somebody else's namespace - `Radio.Root`,
|
|
237
|
+
* `BaseDialog.Root` - so there is nothing of theirs to reach and the honest
|
|
238
|
+
* answer is to name the library. Anything else is a component of theirs and goes
|
|
239
|
+
* through the crosswalk exactly like a child frontier does.
|
|
240
|
+
*/
|
|
241
|
+
let rootOut;
|
|
242
|
+
const rootRaw = String(root ?? "").trim();
|
|
243
|
+
if (rootRaw && /^[A-Z]/.test(rootRaw)) {
|
|
244
|
+
if (rootRaw.includes(".")) {
|
|
245
|
+
rootOut = { from: rootRaw };
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
const slug = resolve?.(rootRaw) ?? safePartName(rootRaw);
|
|
249
|
+
if (slug)
|
|
250
|
+
rootOut = { ref: slug, name: rootRaw };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
205
253
|
if (coerced > 0) {
|
|
206
254
|
notes.push(`${coerced} node${coerced === 1 ? "" : "s"} named a form that does not exist - kept, drawn as what ${coerced === 1 ? "it holds" : "they hold"}`);
|
|
207
255
|
}
|
|
208
256
|
if (budget <= 0) {
|
|
209
257
|
notes.push(`the anatomy was longer than ${MAX_NODES} nodes - the rest is layout, and it is not read`);
|
|
210
258
|
}
|
|
211
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
parts,
|
|
261
|
+
tree,
|
|
262
|
+
composes,
|
|
263
|
+
external,
|
|
264
|
+
notes,
|
|
265
|
+
...(rootOut ? { root: rootOut } : {}),
|
|
266
|
+
};
|
|
212
267
|
}
|
|
213
268
|
/**
|
|
214
269
|
* The OLD flat form, folded into the same result shape.
|
|
@@ -5,7 +5,7 @@ import { generateComponentFiles } from "../component-codegen.js";
|
|
|
5
5
|
import { readProjectConfig, resolveRegistry } from "../config.js";
|
|
6
6
|
import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
|
|
7
7
|
import { body, section, snippet } from "../output.js";
|
|
8
|
-
import { reactMajorOf, readInstalledConvention } from "../project-facts.js";
|
|
8
|
+
import { findCollision, reactMajorOf, readInstalledConvention, } from "../project-facts.js";
|
|
9
9
|
import { fetchComponent, RegistryError } from "../registry.js";
|
|
10
10
|
/**
|
|
11
11
|
* Writes the shared `cn.ts` next to the components, built from THIS project's
|
|
@@ -68,6 +68,43 @@ export async function component(slug, name, opts) {
|
|
|
68
68
|
const config = await readProjectConfig(root);
|
|
69
69
|
const wantInteractive = opts.interactive && hasInteractiveTemplate(res.name);
|
|
70
70
|
if (!opts.artifactsOnly && config.target === "next") {
|
|
71
|
+
/**
|
|
72
|
+
* WE DO NOT TAKE A NAME THEY ARE USING.
|
|
73
|
+
*
|
|
74
|
+
* This wrote with a bare `writeFile`, so it overwrote whatever was there - and
|
|
75
|
+
* a project that already has its own `Button` is the normal case. The
|
|
76
|
+
* crosswalk makes it sharper: their `ToolbarButton` resolves to `button`, so
|
|
77
|
+
* asking for one hands back a `Button`, which is a different component.
|
|
78
|
+
*
|
|
79
|
+
* Re-materializing something WE generated is almost always what they meant, so
|
|
80
|
+
* that keeps going. Shadowing a component of theirs stops and asks.
|
|
81
|
+
*/
|
|
82
|
+
const pascalName = res.name
|
|
83
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
86
|
+
.join("");
|
|
87
|
+
const clash = await findCollision(root, config.componentsDir, res.name, pascalName);
|
|
88
|
+
if (!opts.force && (clash.exported || (clash.file && !clash.ours))) {
|
|
89
|
+
console.log(section("This name is already taken in your project"));
|
|
90
|
+
if (clash.exported) {
|
|
91
|
+
console.log(body(`\`${pascalName}\` is already exported from ${clash.exported}.`));
|
|
92
|
+
}
|
|
93
|
+
if (clash.file && !clash.ours) {
|
|
94
|
+
console.log(body(`${clash.file} exists and we did not write it.`));
|
|
95
|
+
}
|
|
96
|
+
console.log("");
|
|
97
|
+
console.log(body("The recipe and the compiled CSS are on disk either way - only the .tsx was not written. Your options:"));
|
|
98
|
+
console.log("");
|
|
99
|
+
console.log(snippet([
|
|
100
|
+
`npx synthesisui component ${slug} ${res.name} --force`,
|
|
101
|
+
`# or bring it under a name of your own:`,
|
|
102
|
+
`npx synthesisui component ${slug} ${res.name} --as my-${res.name}`,
|
|
103
|
+
]));
|
|
104
|
+
console.log("");
|
|
105
|
+
console.log(body("Nothing of yours was touched. Which name it takes is your call, not ours."));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
71
108
|
const compDir = join(root, config.componentsDir, res.name);
|
|
72
109
|
await mkdir(compDir, { recursive: true });
|
|
73
110
|
let filenames;
|
package/dist/commands/import.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
-
import { resolveAnatomy, resolveFlatParts, } from "../anatomy-read.js";
|
|
3
|
+
import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
|
|
4
4
|
import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
|
|
5
5
|
import { findBrokenRefs } from "../doctor/broken-refs.js";
|
|
6
6
|
import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
|
|
7
7
|
import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
|
|
8
8
|
import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
|
|
9
|
-
import { crosswalk, observedRules } from "../doctor/crosswalk.js";
|
|
9
|
+
import { crosswalk, isLibrary, observedRules } from "../doctor/crosswalk.js";
|
|
10
10
|
import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
|
|
11
11
|
import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
|
|
12
12
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
@@ -207,7 +207,8 @@ function distinctValues(d) {
|
|
|
207
207
|
}
|
|
208
208
|
return kept.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
209
209
|
}
|
|
210
|
-
export async function takeCensus(root) {
|
|
210
|
+
export async function takeCensus(root, opts) {
|
|
211
|
+
const scopeLabel = opts?.scopeLabel;
|
|
211
212
|
const css = await harvestOwnCss(root);
|
|
212
213
|
const table = buildTable({ css, source: "yours" });
|
|
213
214
|
const schemes = parseSchemeBlocks(css);
|
|
@@ -260,6 +261,48 @@ export async function takeCensus(root) {
|
|
|
260
261
|
}
|
|
261
262
|
const d = diagnose(reports);
|
|
262
263
|
const inventory = tallyToInventory(tally);
|
|
264
|
+
/**
|
|
265
|
+
* THE EVIDENCE PASS - a second walk that touches ONE reading.
|
|
266
|
+
*
|
|
267
|
+
* Only the tally. Not `css`, not `declaredValues`, not `looks`, not `defined`,
|
|
268
|
+
* not `reports`: an app's classes are choices made from a scale, and folding
|
|
269
|
+
* them into the census would put the average back where the declaration goes.
|
|
270
|
+
*
|
|
271
|
+
* Deliberately its own tally so the LIBRARY QUESTION is asked of the scope
|
|
272
|
+
* alone. Fold the app in first and every export has a count, `isLibrary` reads
|
|
273
|
+
* false, and the crosswalk starts substituting the components it was just
|
|
274
|
+
* taught to keep.
|
|
275
|
+
*/
|
|
276
|
+
const usageTally = emptyTally();
|
|
277
|
+
const usageSeen = new Set();
|
|
278
|
+
/**
|
|
279
|
+
* THE SCOPE'S OWN PACKAGE NAME IS INTERNAL, always.
|
|
280
|
+
*
|
|
281
|
+
* An app importing the system says `import { Button } from "@acme/ui"`, and the
|
|
282
|
+
* alias reader finds that name by walking the workspace for sibling manifests. It
|
|
283
|
+
* usually does - but when it cannot, every component the app composes files as
|
|
284
|
+
* THIRD-PARTY and the evidence pass counts nothing. The scope is the system by
|
|
285
|
+
* definition, so its name never needs discovering.
|
|
286
|
+
*/
|
|
287
|
+
const scopePkg = await readFile(join(root, "package.json"), "utf8")
|
|
288
|
+
.then((raw) => JSON.parse(raw).name ?? null)
|
|
289
|
+
.catch(() => null);
|
|
290
|
+
const shared = scopePkg ? [...internal, scopePkg] : internal;
|
|
291
|
+
for (const u of opts?.usage ?? []) {
|
|
292
|
+
const uInternal = await internalSpecifiers(u.path);
|
|
293
|
+
for await (const file of walk(u.path)) {
|
|
294
|
+
const src = await readFile(file, "utf8").catch(() => "");
|
|
295
|
+
if (!src)
|
|
296
|
+
continue;
|
|
297
|
+
const rel = join(u.label, relative(u.path, file));
|
|
298
|
+
if (/(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/.test(rel)) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
usageSeen.add(rel);
|
|
302
|
+
scanComponentsInto(usageTally, rel, src, [...shared, ...uInternal]);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const usedThere = tallyToInventory(usageTally, 400);
|
|
263
306
|
/**
|
|
264
307
|
* THE TWO READINGS BECOME ONE LIST before anything is judged.
|
|
265
308
|
*
|
|
@@ -285,6 +328,9 @@ export async function takeCensus(root) {
|
|
|
285
328
|
...Object.fromEntries(d.flags.map((f) => [f, ["true"]])),
|
|
286
329
|
},
|
|
287
330
|
propFiles: {},
|
|
331
|
+
// Their own rung, off the folder. It travels from the DEFINITION because
|
|
332
|
+
// that is the only reading that knows which file the component lives in.
|
|
333
|
+
...(d.tier ? { tier: d.tier } : {}),
|
|
288
334
|
}));
|
|
289
335
|
/**
|
|
290
336
|
* A composed component that is ALSO defined keeps its usage and gains the
|
|
@@ -306,20 +352,104 @@ export async function takeCensus(root) {
|
|
|
306
352
|
const declaredBy = new Map(defined.map((d) => [d.name, d]));
|
|
307
353
|
const enriched = inventory.map((c) => {
|
|
308
354
|
const d = declaredBy.get(c.name);
|
|
309
|
-
if (!d
|
|
355
|
+
if (!d)
|
|
310
356
|
return c;
|
|
311
|
-
|
|
357
|
+
const tier = d.tier ? { tier: d.tier } : {};
|
|
358
|
+
if (Object.keys(d.axes).length === 0) {
|
|
359
|
+
return Object.keys(tier).length > 0 ? { ...c, ...tier } : c;
|
|
360
|
+
}
|
|
361
|
+
return { ...c, declaredAxes: d.axes, ...tier };
|
|
312
362
|
});
|
|
313
|
-
const
|
|
363
|
+
const scoped = [...enriched, ...fromTypes];
|
|
364
|
+
/**
|
|
365
|
+
* THE EVIDENCE, FOLDED IN - counts and chosen values, never new components.
|
|
366
|
+
*
|
|
367
|
+
* A component the app has and the system does not is the app's own, and adopting
|
|
368
|
+
* it here would make the system a union of two codebases. It is reported instead:
|
|
369
|
+
* a number somebody can act on beats a component nobody asked for.
|
|
370
|
+
*
|
|
371
|
+
* The counts ADD because they are the same act measured in two places, and the
|
|
372
|
+
* prop values union because both are things somebody typed. `propFiles` is what
|
|
373
|
+
* the laws read, and it is the reason this pass exists: three files agreeing is
|
|
374
|
+
* what makes a habit, and a library has one file per component.
|
|
375
|
+
*/
|
|
376
|
+
const own = new Set(scoped.map((c) => c.name));
|
|
377
|
+
const evidence = new Map(usedThere.filter((c) => !c.from).map((c) => [c.name, c]));
|
|
378
|
+
const merged = scoped.map((c) => {
|
|
379
|
+
const e = evidence.get(c.name);
|
|
380
|
+
if (!e)
|
|
381
|
+
return c;
|
|
382
|
+
/**
|
|
383
|
+
* A COMPONENT COMPOSED NOWHERE IN THE SCOPE carries its DECLARATION in `props`,
|
|
384
|
+
* because for a library that is the only reading there is. The moment real usage
|
|
385
|
+
* arrives, that declaration has to move where declarations live - otherwise the
|
|
386
|
+
* two readings merge and `props` stops meaning "what the code passed".
|
|
387
|
+
*
|
|
388
|
+
* It is the same mistake `declaredAxes` exists to prevent, arriving from the
|
|
389
|
+
* other side: unioned, a Button declaring solid|ghost and only ever passed solid
|
|
390
|
+
* reports two options, and the law that all four files agree can never be
|
|
391
|
+
* written.
|
|
392
|
+
*/
|
|
393
|
+
const declaredOnly = (c.count ?? 0) === 0;
|
|
394
|
+
const props = declaredOnly ? {} : { ...c.props };
|
|
395
|
+
for (const [prop, values] of Object.entries(e.props)) {
|
|
396
|
+
props[prop] = [...new Set([...(props[prop] ?? []), ...values])];
|
|
397
|
+
}
|
|
398
|
+
const declaredAxes = c.declaredAxes ?? (declaredOnly ? c.props : undefined);
|
|
399
|
+
const propFiles = { ...(c.propFiles ?? {}) };
|
|
400
|
+
for (const [prop, n] of Object.entries(e.propFiles ?? {})) {
|
|
401
|
+
propFiles[prop] = (propFiles[prop] ?? 0) + n;
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
...c,
|
|
405
|
+
count: (c.count ?? 0) + (e.count ?? 0),
|
|
406
|
+
files: (c.files ?? 0) + (e.files ?? 0),
|
|
407
|
+
props,
|
|
408
|
+
propFiles,
|
|
409
|
+
...(declaredAxes && Object.keys(declaredAxes).length > 0
|
|
410
|
+
? { declaredAxes }
|
|
411
|
+
: {}),
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
const theirsOnly = usedThere.filter((c) => !c.from && !own.has(c.name));
|
|
314
415
|
// The verdict travels WITH the payload: the platform reads one reading rather
|
|
315
416
|
// than computing a second opinion from the same numbers, which is how two
|
|
316
417
|
// implementations of the same judgement start disagreeing.
|
|
317
|
-
|
|
418
|
+
/**
|
|
419
|
+
* IS THIS SCOPE A LIBRARY? The answer changes what the crosswalk is FOR.
|
|
420
|
+
*
|
|
421
|
+
* In an app, "your Pill reads as our badge" saves them a duplicate. In a library it
|
|
422
|
+
* would delete their component - and 16 of 37 in a real one were deleted that way
|
|
423
|
+
* (dono, 01/08). Measured, not guessed: a library declares and does not compose
|
|
424
|
+
* itself. See `isLibrary`.
|
|
425
|
+
*/
|
|
426
|
+
const library = isLibrary(scoped);
|
|
427
|
+
const verdicts = new Map(crosswalk(merged, { library }).map((r) => [
|
|
318
428
|
r.component.name,
|
|
319
429
|
{ bucket: r.bucket, canonical: r.canonical, because: r.because },
|
|
320
430
|
]));
|
|
431
|
+
if (usedThere.length > 0) {
|
|
432
|
+
console.log("");
|
|
433
|
+
console.log(body(`Read ${usageSeen.size} more file${usageSeen.size === 1 ? "" : "s"} for EVIDENCE only - ${(opts?.usage ?? []).map((u) => paint.strong(u.label)).join(", ")}. What comes from there is how often each component is used and which values get picked, which is what turns a settled choice into a law. The palette, the scales and the convention stay from ${paint.strong(scopeLabel ?? "the system")}: an app's average is not a scale, it is a set of choices made from one.`));
|
|
434
|
+
if (theirsOnly.length > 0) {
|
|
435
|
+
const shown = theirsOnly.slice(0, 6).map((c) => c.name);
|
|
436
|
+
console.log(body(paint.faint(`(${theirsOnly.length} component${theirsOnly.length === 1 ? "" : "s"} live there and not in the system - ${shown.join(", ")}${theirsOnly.length > shown.length ? ", …" : ""}. Left out on purpose: the system is one place, and a union of two codebases is not a system. Point --scope at them if they belong in it.)`)));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (library) {
|
|
440
|
+
console.log("");
|
|
441
|
+
console.log(body(`This scope DECLARES its components and composes almost none of them inside itself - which is what a component library looks like from the inside. So every component it exports arrives as YOURS, with its own recipe. Where one reads like something in our catalogue, that is recorded as a note instead of replacing it.`));
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* THE LAWS READ THE MERGED LIST, not the scope's.
|
|
445
|
+
*
|
|
446
|
+
* `RULE_MIN_FILES` is three, and a library has one file per component - so
|
|
447
|
+
* measured against the scope alone, "every file that passes it agrees" can never
|
|
448
|
+
* be true and the habit rung stays empty forever. The app is where the agreeing
|
|
449
|
+
* happens, which is the whole reason `--usage` exists.
|
|
450
|
+
*/
|
|
321
451
|
const laws = new Map();
|
|
322
|
-
for (const r of observedRules(
|
|
452
|
+
for (const r of observedRules(merged)) {
|
|
323
453
|
laws.set(r.component, [...(laws.get(r.component) ?? []), r.text]);
|
|
324
454
|
}
|
|
325
455
|
const components = merged.map((c) => ({
|
|
@@ -361,6 +491,9 @@ export async function takeCensus(root) {
|
|
|
361
491
|
observed: distinctValues(d),
|
|
362
492
|
...(components.length > 0 ? { components } : {}),
|
|
363
493
|
...(defined.length > 0 ? { defined } : {}),
|
|
494
|
+
...((opts?.usage ?? []).length > 0
|
|
495
|
+
? { usage: (opts?.usage ?? []).map((u) => u.label) }
|
|
496
|
+
: {}),
|
|
364
497
|
totals: {
|
|
365
498
|
scanned: d.scanned,
|
|
366
499
|
values: d.findings.length,
|
|
@@ -670,9 +803,22 @@ async function sayIfWorkspace(root) {
|
|
|
670
803
|
console.log(body("That is a fine DIAGNOSIS and a poor system: a light app and a dark one"));
|
|
671
804
|
console.log(body("average into a palette that is neither."));
|
|
672
805
|
console.log("");
|
|
806
|
+
/**
|
|
807
|
+
* BOTH ROLES IN ONE LINE, because a person reading this has both folders open.
|
|
808
|
+
*
|
|
809
|
+
* `--scope` alone is where the library came back with no laws at all: the system
|
|
810
|
+
* is one place, but the EVIDENCE that a choice is settled is somewhere else, and
|
|
811
|
+
* naming only the first left the second unreachable (dono, 01/08).
|
|
812
|
+
*/
|
|
813
|
+
const usageFlags = apps
|
|
814
|
+
.slice(0, 2)
|
|
815
|
+
.map((a) => `--usage ${a}`)
|
|
816
|
+
.join(" ");
|
|
673
817
|
if (shared.length > 0) {
|
|
674
818
|
console.log(body("The shape that works, if your vocabulary is shared:"));
|
|
675
|
-
console.log(body(` ${paint.strong(`synthesisui import --scope ${shared[0]}
|
|
819
|
+
console.log(body(` ${paint.strong(`synthesisui import --scope ${shared[0]} ${usageFlags}`)}`));
|
|
820
|
+
console.log(body(paint.faint(` --scope is the SYSTEM: the tokens, the components, the way you name a class. One place.`)));
|
|
821
|
+
console.log(body(paint.faint(` --usage is the EVIDENCE: how often each component is used and which values get picked. As many as you have.`)));
|
|
676
822
|
}
|
|
677
823
|
else {
|
|
678
824
|
console.log(body("The shape that works:"));
|
|
@@ -924,10 +1070,28 @@ async function resolveReadParts(census, root) {
|
|
|
924
1070
|
let edges = 0;
|
|
925
1071
|
const libs = new Set();
|
|
926
1072
|
const notes = new Set();
|
|
1073
|
+
/**
|
|
1074
|
+
* WHAT A NAME IN THEIR CODE REACHES IN THE SYSTEM - from the crosswalk.
|
|
1075
|
+
*
|
|
1076
|
+
* The census already decided this for every component it read: `Tag` is
|
|
1077
|
+
* `nearly`, canonical `badge`; `ToolbarButton` is `nearly`, canonical `button`;
|
|
1078
|
+
* `Card` `exists` as `card`. The first version of the frontier kebab-cased the
|
|
1079
|
+
* name and looked for `ds-tag`, which does not exist - so a component whose
|
|
1080
|
+
* recipe was sitting right there drew as a grey chip (dono, 01/08).
|
|
1081
|
+
*/
|
|
1082
|
+
const crosswalked = new Map();
|
|
1083
|
+
for (const c of census.components ?? []) {
|
|
1084
|
+
if (c.from)
|
|
1085
|
+
continue; // a package's component is not one of theirs
|
|
1086
|
+
const target = c.canonical ?? (c.bucket === "exclusive" ? c.name : null);
|
|
1087
|
+
if (target)
|
|
1088
|
+
crosswalked.set(c.name, safePartName(target));
|
|
1089
|
+
}
|
|
1090
|
+
const resolveRef = (name) => crosswalked.get(name) ?? null;
|
|
927
1091
|
for (const [component, entry] of Object.entries(read)) {
|
|
928
1092
|
const anatomy = entry?.anatomy;
|
|
929
1093
|
const resolved = Array.isArray(anatomy) && anatomy.length > 0
|
|
930
|
-
? resolveAnatomy(anatomy, declared, deps)
|
|
1094
|
+
? resolveAnatomy(anatomy, declared, deps, resolveRef, entry?.root)
|
|
931
1095
|
: Array.isArray(entry?.parts) && entry.parts.length > 0
|
|
932
1096
|
? resolveFlatParts(entry.parts, declared)
|
|
933
1097
|
: null;
|
|
@@ -942,6 +1106,7 @@ async function resolveReadParts(census, root) {
|
|
|
942
1106
|
...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
|
|
943
1107
|
...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
|
|
944
1108
|
...(resolved.external.length > 0 ? { external: resolved.external } : {}),
|
|
1109
|
+
...(resolved.root ? { root: resolved.root } : {}),
|
|
945
1110
|
};
|
|
946
1111
|
looks[component] = look
|
|
947
1112
|
? {
|
|
@@ -998,11 +1163,40 @@ export async function runImport(opts) {
|
|
|
998
1163
|
* decisions live in one package and the governance belongs at the root, and
|
|
999
1164
|
* conflating the two meant every re-measure scattered another census.
|
|
1000
1165
|
*/
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1166
|
+
const tidy = (p) => p
|
|
1167
|
+
.trim()
|
|
1003
1168
|
.replace(/^\.\/+/, "")
|
|
1004
1169
|
.replace(/\/+$/, "");
|
|
1170
|
+
const scope = opts.scope ? tidy(opts.scope) : undefined;
|
|
1005
1171
|
const readFrom = scope ? join(root, scope) : root;
|
|
1172
|
+
/**
|
|
1173
|
+
* THE SECOND ROLE. `--scope` is the SYSTEM - one place, and the only source of
|
|
1174
|
+
* the palette, the scales and the convention. `--usage` is the EVIDENCE - as
|
|
1175
|
+
* many places as they have, and the only source of counts, chosen values and
|
|
1176
|
+
* laws.
|
|
1177
|
+
*
|
|
1178
|
+
* They were one flag, and one flag could only answer one of the two questions.
|
|
1179
|
+
* Pointed at a real library, `usage` came back empty on all 23 components and
|
|
1180
|
+
* the habit rung of the ladder was unreachable by construction: a law needs
|
|
1181
|
+
* three files that agree and a library has one file per component (dono, 01/08).
|
|
1182
|
+
*/
|
|
1183
|
+
const usage = [];
|
|
1184
|
+
for (const raw of opts.usage ?? []) {
|
|
1185
|
+
const label = tidy(raw);
|
|
1186
|
+
if (!label || label === scope)
|
|
1187
|
+
continue;
|
|
1188
|
+
const path = join(root, label);
|
|
1189
|
+
const ok = await stat(path)
|
|
1190
|
+
.then((st) => st.isDirectory())
|
|
1191
|
+
.catch(() => false);
|
|
1192
|
+
if (!ok) {
|
|
1193
|
+
console.log(section("Import"));
|
|
1194
|
+
console.log(body(`\`--usage ${label}\` is not a folder under ${paint.strong(root)}. Nothing was read.`));
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (!usage.some((u) => u.label === label))
|
|
1198
|
+
usage.push({ path, label });
|
|
1199
|
+
}
|
|
1006
1200
|
// A census handed to us (an agent annotated it) is sent as-is; the numbers
|
|
1007
1201
|
// inside were still ours to begin with.
|
|
1008
1202
|
let census;
|
|
@@ -1041,7 +1235,10 @@ export async function runImport(opts) {
|
|
|
1041
1235
|
}
|
|
1042
1236
|
else {
|
|
1043
1237
|
console.log(section("Reading your project"));
|
|
1044
|
-
census = await takeCensus(readFrom
|
|
1238
|
+
census = await takeCensus(readFrom, {
|
|
1239
|
+
...(usage.length > 0 ? { usage } : {}),
|
|
1240
|
+
...(scope ? { scopeLabel: scope } : {}),
|
|
1241
|
+
});
|
|
1045
1242
|
if (scope)
|
|
1046
1243
|
census.scope = scope;
|
|
1047
1244
|
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -65,6 +65,20 @@ const TOOLS = [
|
|
|
65
65
|
description: "The components this design system defines, with what each is for. Look here BEFORE writing any UI element from scratch - if something covers the purpose, materialize it with add_component instead.",
|
|
66
66
|
inputSchema: { type: "object", properties: {} },
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
name: "describe_component",
|
|
70
|
+
description: "Everything the system knows about ONE component: what it is made of, what it composes, WHICH LIBRARIES IT NEEDS, and the rules that govern it. Call this before writing code that uses a component - `list_components` gives you a name and a sentence, and a name does not tell you that the editor needs @tiptap/react or that a card is built out of a metric card.",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: {
|
|
74
|
+
name: {
|
|
75
|
+
type: "string",
|
|
76
|
+
description: "Component name from list_components.",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
required: ["name"],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
68
82
|
{
|
|
69
83
|
name: "add_component",
|
|
70
84
|
description: "Materialize a component from the design system as real typed code in this project, ready to import and extend. Use it yourself - the person who asked for a feature should never have to know component names.",
|
|
@@ -191,6 +205,118 @@ async function listComponents(root) {
|
|
|
191
205
|
...rows.sort(),
|
|
192
206
|
].join("\n");
|
|
193
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* ONE COMPONENT, IN FULL - the round trip that did not exist.
|
|
210
|
+
*
|
|
211
|
+
* `list_components` returns a name and a description, so an agent building with
|
|
212
|
+
* `ds-text-editor` had no way to know it needs `@tiptap/react` before writing the
|
|
213
|
+
* first line. The anatomy, the composition chain and the required libraries are all
|
|
214
|
+
* in the installed contract; nothing was asking for them.
|
|
215
|
+
*
|
|
216
|
+
* It is also the confirmation the skill never had. After an import, reading this
|
|
217
|
+
* back says what the platform UNDERSTOOD - so a reading that came out thin is
|
|
218
|
+
* visible instead of being discovered later in a preview.
|
|
219
|
+
*/
|
|
220
|
+
async function describeComponent(root, name) {
|
|
221
|
+
const { documents, requires } = await loadSystem(root);
|
|
222
|
+
let recipe;
|
|
223
|
+
for (const doc of documents) {
|
|
224
|
+
const d = doc;
|
|
225
|
+
recipe = d.components?.[name] ?? d.blocks?.[name];
|
|
226
|
+
if (recipe)
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
if (!recipe) {
|
|
230
|
+
return `This system has no component called "${name}". Run list_components to see what it does have - and if nothing covers what you need, file request_component rather than writing one from scratch.`;
|
|
231
|
+
}
|
|
232
|
+
const out = [
|
|
233
|
+
`${name}${recipe.description ? ` - ${recipe.description}` : ""}`,
|
|
234
|
+
];
|
|
235
|
+
// THEIR OWN RUNG, when their repo has one. An agent about to compose this should
|
|
236
|
+
// know whether it is a primitive or a whole region before it starts.
|
|
237
|
+
if (recipe.tier) {
|
|
238
|
+
out.push(recipe.tier === "atom"
|
|
239
|
+
? "An atom in your own ladder - nothing else in the system breaks down into it."
|
|
240
|
+
: recipe.tier === "molecule"
|
|
241
|
+
? "A molecule in your own ladder - built out of your atoms, so reach for those inside it."
|
|
242
|
+
: "An organism in your own ladder - a whole region of a screen, so it is more likely to be composed than to be composed into.");
|
|
243
|
+
}
|
|
244
|
+
// WHAT IT SITS ON. 17 of 23 components in a real library carry no surface of
|
|
245
|
+
// their own, because the surface belongs to what they return.
|
|
246
|
+
const p = recipe.preview;
|
|
247
|
+
if (p?.rootRef) {
|
|
248
|
+
out.push("", `Sits on: ${p.rootRef}${p.rootName && p.rootName !== p.rootRef ? ` (your ${p.rootName})` : ""} - its background, border and radius come from there, not from this component.`);
|
|
249
|
+
}
|
|
250
|
+
else if (p?.rootFrom) {
|
|
251
|
+
out.push("", `Sits on: ${p.rootFrom} - a third-party root. Its markup and behaviour are that library's.`);
|
|
252
|
+
}
|
|
253
|
+
// WHAT IT IS MADE OF, and where it ends.
|
|
254
|
+
const composes = [];
|
|
255
|
+
const libs = [];
|
|
256
|
+
const shape = [];
|
|
257
|
+
const walk = (nodes, depth) => {
|
|
258
|
+
for (const raw of nodes) {
|
|
259
|
+
const n = raw;
|
|
260
|
+
const pad = " ".repeat(depth + 1);
|
|
261
|
+
if (n.as === "component" && n.ref) {
|
|
262
|
+
shape.push(`${pad}<${n.ref}>${n.refName && n.refName !== n.ref ? ` (your ${n.refName})` : ""}`);
|
|
263
|
+
if (!composes.includes(n.ref))
|
|
264
|
+
composes.push(n.ref);
|
|
265
|
+
}
|
|
266
|
+
else if (n.as === "external" && n.from) {
|
|
267
|
+
shape.push(`${pad}${n.from} (third party - not ours to render)`);
|
|
268
|
+
if (!libs.includes(n.from))
|
|
269
|
+
libs.push(n.from);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
shape.push(`${pad}${n.as}${n.part ? ` .${name}-${n.part}` : ""}`);
|
|
273
|
+
}
|
|
274
|
+
if (Array.isArray(n.children))
|
|
275
|
+
walk(n.children, depth + 1);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
if (Array.isArray(p?.parts) && p.parts.length > 0) {
|
|
279
|
+
walk(p.parts, 0);
|
|
280
|
+
out.push("", "Made of:", ...shape);
|
|
281
|
+
}
|
|
282
|
+
else if (recipe.parts && Object.keys(recipe.parts).length > 0) {
|
|
283
|
+
out.push("", `Parts: ${Object.keys(recipe.parts).join(", ")} - compose them inside it.`);
|
|
284
|
+
}
|
|
285
|
+
if (composes.length > 0) {
|
|
286
|
+
out.push("", `Built out of: ${composes.join(", ")}. Change one of those in one place rather than reproducing it here, and call describe_component on it before you do.`);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* WHAT IT NEEDS INSTALLED - the whole reason this tool earns its place.
|
|
290
|
+
*
|
|
291
|
+
* From `requires.json`, filtered by the stack at install time, so the answer is
|
|
292
|
+
* about THIS project. Never installs: a dependency has a licence, a bundle cost
|
|
293
|
+
* and a maintainer attached, so the agent reports and the person decides.
|
|
294
|
+
*/
|
|
295
|
+
const needed = requires.filter((r) => (r.applies ?? []).includes(name));
|
|
296
|
+
if (needed.length > 0 || libs.length > 0) {
|
|
297
|
+
const named = [
|
|
298
|
+
...new Set([...needed.map((r) => r.requires), ...libs]),
|
|
299
|
+
];
|
|
300
|
+
out.push("", `Needs installed: ${named.join(", ")}.`, "Check the manifest before you write against it. If it is missing, say so and ASK - do not install it yourself.");
|
|
301
|
+
for (const r of needed) {
|
|
302
|
+
if (r.pinned)
|
|
303
|
+
out.push(` ${r.requires} ${r.pinned}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// THE RULES, scoped. A relation names both, which is what an agent needs in
|
|
307
|
+
// order not to assemble it wrongly.
|
|
308
|
+
const laws = [...(recipe.usage ?? [])];
|
|
309
|
+
if (laws.length > 0) {
|
|
310
|
+
out.push("", "Rules for this component:", ...laws.map((l) => ` ${l}`));
|
|
311
|
+
}
|
|
312
|
+
if (needed.length > 0) {
|
|
313
|
+
out.push(...needed.map((r) => ` ${r.text}`));
|
|
314
|
+
}
|
|
315
|
+
if (laws.length === 0 && needed.length === 0) {
|
|
316
|
+
out.push("", "No rules govern this component yet. Build with it, and if you find yourself working around it, file request_component with the reasoning.");
|
|
317
|
+
}
|
|
318
|
+
return out.join("\n");
|
|
319
|
+
}
|
|
194
320
|
async function addComponent(root, name) {
|
|
195
321
|
const { table } = await loadSystem(root);
|
|
196
322
|
if (!table.slug)
|
|
@@ -207,6 +333,8 @@ async function callTool(root, name, args) {
|
|
|
207
333
|
return text(await findToken(root, String(args.value ?? "")));
|
|
208
334
|
case "list_components":
|
|
209
335
|
return text(await listComponents(root));
|
|
336
|
+
case "describe_component":
|
|
337
|
+
return text(await describeComponent(root, String(args.name ?? "")));
|
|
210
338
|
case "add_component":
|
|
211
339
|
return text(await addComponent(root, String(args.name ?? "")));
|
|
212
340
|
case "request_component": {
|