synthesisui 0.16.80 → 0.16.82
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 +230 -11
- 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/transcribe.js +116 -17
- package/dist/doctor/variant-read.js +584 -0
- package/dist/progress.js +71 -0
- package/dist/skill-import.js +94 -1
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -2,9 +2,12 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { basename, dirname, join, relative } from "node:path";
|
|
3
3
|
import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
|
|
4
4
|
import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
|
|
5
|
+
import { architectureRule, describeArchitecture, describeChoice, detectArchitectures, } from "../doctor/architecture.js";
|
|
5
6
|
import { findBrokenRefs } from "../doctor/broken-refs.js";
|
|
7
|
+
import { nestingRules, propRules, readDefinitionProps, readNesting, } from "../doctor/call-sites.js";
|
|
6
8
|
import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
|
|
7
9
|
import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
|
|
10
|
+
import { describeGate, gateComponent, groupSkips, } from "../doctor/component-gate.js";
|
|
8
11
|
import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
|
|
9
12
|
import { crosswalk, isLibrary, observedRules } from "../doctor/crosswalk.js";
|
|
10
13
|
import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
|
|
@@ -13,7 +16,9 @@ import { diagnose, scanSource } from "../doctor/scan.js";
|
|
|
13
16
|
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
14
17
|
import { buildTable } from "../doctor/tokens.js";
|
|
15
18
|
import { rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
|
|
19
|
+
import { transcribeVariants } from "../doctor/variant-read.js";
|
|
16
20
|
import { body, paint, section } from "../output.js";
|
|
21
|
+
import { startProgress } from "../progress.js";
|
|
17
22
|
import { detectStack, resolveDeps } from "../stack.js";
|
|
18
23
|
import { walk, walkAll } from "./doctor.js";
|
|
19
24
|
/**
|
|
@@ -216,6 +221,8 @@ export async function takeCensus(root, opts) {
|
|
|
216
221
|
const tally = emptyTally();
|
|
217
222
|
const internal = await internalSpecifiers(root);
|
|
218
223
|
const defined = [];
|
|
224
|
+
/** What the gate turned away, with the reason. Never dropped in silence. */
|
|
225
|
+
const skips = [];
|
|
219
226
|
// Kept for the reference check below, which needs every file at once: a name
|
|
220
227
|
// is only broken if NOTHING declares it, anywhere.
|
|
221
228
|
const sources = [];
|
|
@@ -227,11 +234,41 @@ export async function takeCensus(root, opts) {
|
|
|
227
234
|
declaredValues.set(m[1], m[2].trim());
|
|
228
235
|
}
|
|
229
236
|
const looks = {};
|
|
237
|
+
/**
|
|
238
|
+
* The directory shape, gathered while the files are already being walked - so
|
|
239
|
+
* naming how the project is organised costs no second pass. See `architecture.ts`.
|
|
240
|
+
*/
|
|
241
|
+
const dirs = new Map();
|
|
242
|
+
/** Parent → child → the files that put one inside the other. */
|
|
243
|
+
const nesting = new Map();
|
|
244
|
+
/** Component → what its own definition names and whether it forwards the rest. */
|
|
245
|
+
const propsOf = new Map();
|
|
246
|
+
/** Component → the axes its own `cva` declares. See `variant-read.ts`. */
|
|
247
|
+
const variantAxes = new Map();
|
|
248
|
+
/**
|
|
249
|
+
* A census over a real monorepo reads 2797 files, and the terminal said nothing for
|
|
250
|
+
* most of it. Twenty silent minutes is indistinguishable from twenty broken ones
|
|
251
|
+
* (dono, 01/08).
|
|
252
|
+
*/
|
|
253
|
+
const progress = startProgress("Reading");
|
|
230
254
|
for await (const file of walk(root)) {
|
|
231
255
|
const src = await readFile(file, "utf8").catch(() => "");
|
|
232
256
|
if (!src)
|
|
233
257
|
continue;
|
|
234
258
|
const rel = relative(root, file);
|
|
259
|
+
progress.step(rel);
|
|
260
|
+
if (/\.(tsx|jsx|vue|svelte)$/i.test(rel)) {
|
|
261
|
+
const parts = rel.split("/");
|
|
262
|
+
// Every ancestor learns the name of the child directly under it, which is all
|
|
263
|
+
// the shape detector needs and is one pass rather than a tree walk.
|
|
264
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
265
|
+
const at = parts.slice(0, i).join("/");
|
|
266
|
+
const entry = dirs.get(at) ?? { dirs: new Set(), files: 0 };
|
|
267
|
+
entry.dirs.add(parts[i]);
|
|
268
|
+
entry.files += 1;
|
|
269
|
+
dirs.set(at, entry);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
235
272
|
sources.push({ file: rel, source: src });
|
|
236
273
|
reports.push(scanSource(rel, src, table));
|
|
237
274
|
// Stories and tests compose components to SHOW them; counting those as the
|
|
@@ -239,9 +276,39 @@ export async function takeCensus(root, opts) {
|
|
|
239
276
|
// they were set aside.
|
|
240
277
|
if (!/(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/.test(rel)) {
|
|
241
278
|
scanComponentsInto(tally, rel, src, internal);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
279
|
+
/**
|
|
280
|
+
* The other half: what this file EXPORTS, with the axes its types declare. A
|
|
281
|
+
* library composes almost nothing and exports everything.
|
|
282
|
+
*
|
|
283
|
+
* THROUGH THE GATE, which is the difference between 37 and 704. Every test in
|
|
284
|
+
* it is about CATEGORY - a route, a provider, a config object - never quality,
|
|
285
|
+
* and every rejection is carried with its reason.
|
|
286
|
+
*/
|
|
287
|
+
// HOW IT IS USED, not only what it is: which props people pass, what it passes
|
|
288
|
+
// on, and what shows up inside it. See `call-sites.ts`.
|
|
289
|
+
readNesting(rel, src, nesting);
|
|
290
|
+
const found = [];
|
|
291
|
+
for (const d of scanDefinitions(rel, src)) {
|
|
292
|
+
const verdict = gateComponent({
|
|
293
|
+
name: d.name,
|
|
294
|
+
file: rel,
|
|
295
|
+
source: src,
|
|
296
|
+
internal,
|
|
297
|
+
});
|
|
298
|
+
if (verdict.ok) {
|
|
299
|
+
found.push(d);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
skips.push({
|
|
303
|
+
name: d.name,
|
|
304
|
+
file: rel,
|
|
305
|
+
why: verdict.why,
|
|
306
|
+
because: verdict.because,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
for (const d of found) {
|
|
310
|
+
propsOf.set(d.name, readDefinitionProps(d.name, src));
|
|
311
|
+
}
|
|
245
312
|
defined.push(...found);
|
|
246
313
|
// THE LOOK, from their own class names. One transcription per file, keyed
|
|
247
314
|
// by the component it defines - the root element's classes are the
|
|
@@ -249,18 +316,57 @@ export async function takeCensus(root, opts) {
|
|
|
249
316
|
// not attempt.
|
|
250
317
|
if (found.length > 0) {
|
|
251
318
|
const t = transcribe(rootClasses(src), declaredValues);
|
|
252
|
-
|
|
319
|
+
/**
|
|
320
|
+
* THE VARIANT STYLES, FOLDED IN.
|
|
321
|
+
*
|
|
322
|
+
* `rootClasses` reads the class string ON the element, which for a component
|
|
323
|
+
* built with `cva` is a call - `className={cn(buttonVariants({ variant }))}` -
|
|
324
|
+
* and carries none of the nine variants' colours. The definition does, in an
|
|
325
|
+
* object literal, and reading it is deterministic (see `variant-read.ts`).
|
|
326
|
+
*
|
|
327
|
+
* Folded here rather than reported separately because this is the one funnel:
|
|
328
|
+
* the reader was computing 23 layers for a real Button and nobody was asking
|
|
329
|
+
* for the result, which is the "valid and unread" failure this pipeline has
|
|
330
|
+
* paid for twice already.
|
|
331
|
+
*/
|
|
332
|
+
const v = transcribeVariants(src, declaredValues);
|
|
333
|
+
const base = { ...v.base.base, ...t.base };
|
|
334
|
+
const states = { ...v.base.states, ...t.states };
|
|
335
|
+
const size = Object.keys(base).length +
|
|
253
336
|
Object.keys(t.dark).length +
|
|
254
|
-
Object.keys(
|
|
337
|
+
Object.keys(states).length +
|
|
338
|
+
v.layers.length;
|
|
255
339
|
const tag = rootTag(src);
|
|
256
340
|
if (size > 0 || tag) {
|
|
257
|
-
looks[found[0].name] =
|
|
341
|
+
looks[found[0].name] = {
|
|
342
|
+
...t,
|
|
343
|
+
base,
|
|
344
|
+
states,
|
|
345
|
+
...(tag ? { rootTag: tag } : {}),
|
|
346
|
+
...(v.layers.length > 0 ? { layers: v.layers } : {}),
|
|
347
|
+
...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
|
|
348
|
+
...(Object.keys(v.defaults).length > 0
|
|
349
|
+
? { defaults: v.defaults }
|
|
350
|
+
: {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
// Their variant DEFINITION beats what usage happened to pick, and it is the
|
|
354
|
+
// better source: usage shows what somebody chose, the declaration shows what
|
|
355
|
+
// the author designed.
|
|
356
|
+
if (Object.keys(v.axes).length > 0) {
|
|
357
|
+
variantAxes.set(found[0].name, v.axes);
|
|
258
358
|
}
|
|
259
359
|
}
|
|
260
360
|
}
|
|
261
361
|
}
|
|
362
|
+
progress.done();
|
|
363
|
+
const architectures = detectArchitectures([...dirs.entries()].map(([path, v]) => ({
|
|
364
|
+
path: path || ".",
|
|
365
|
+
dirs: [...v.dirs],
|
|
366
|
+
files: v.files,
|
|
367
|
+
})));
|
|
262
368
|
const d = diagnose(reports);
|
|
263
|
-
const
|
|
369
|
+
const allComposed = tallyToInventory(tally, 400);
|
|
264
370
|
/**
|
|
265
371
|
* THE EVIDENCE PASS - a second walk that touches ONE reading.
|
|
266
372
|
*
|
|
@@ -275,6 +381,7 @@ export async function takeCensus(root, opts) {
|
|
|
275
381
|
*/
|
|
276
382
|
const usageTally = emptyTally();
|
|
277
383
|
const usageSeen = new Set();
|
|
384
|
+
const usageProgress = (opts?.usage ?? []).length > 0 ? startProgress("Evidence") : null;
|
|
278
385
|
/**
|
|
279
386
|
* THE SCOPE'S OWN PACKAGE NAME IS INTERNAL, always.
|
|
280
387
|
*
|
|
@@ -299,9 +406,11 @@ export async function takeCensus(root, opts) {
|
|
|
299
406
|
continue;
|
|
300
407
|
}
|
|
301
408
|
usageSeen.add(rel);
|
|
409
|
+
usageProgress?.step(rel);
|
|
302
410
|
scanComponentsInto(usageTally, rel, src, [...shared, ...uInternal]);
|
|
303
411
|
}
|
|
304
412
|
}
|
|
413
|
+
usageProgress?.done();
|
|
305
414
|
const usedThere = tallyToInventory(usageTally, 400);
|
|
306
415
|
/**
|
|
307
416
|
* THE TWO READINGS BECOME ONE LIST before anything is judged.
|
|
@@ -316,6 +425,16 @@ export async function takeCensus(root, opts) {
|
|
|
316
425
|
* is the better source anyway - usage shows what somebody picked, the
|
|
317
426
|
* declaration shows what the author designed.
|
|
318
427
|
*/
|
|
428
|
+
/**
|
|
429
|
+
* THE GATE APPLIES FROM BOTH SIDES.
|
|
430
|
+
*
|
|
431
|
+
* A page reaches the inventory two ways: exported (caught above) and COMPOSED -
|
|
432
|
+
* `<AuthLayout>` wrapping a tree is a JSX tag like any other. Filtering only the
|
|
433
|
+
* definitions would have let it back in through the usage door under the same
|
|
434
|
+
* name, and the vitrine cannot tell which door a name came through.
|
|
435
|
+
*/
|
|
436
|
+
const turnedAway = new Set(skips.map((s) => s.name));
|
|
437
|
+
const inventory = allComposed.filter((c) => !turnedAway.has(c.name));
|
|
319
438
|
const composed = new Set(inventory.map((c) => c.name));
|
|
320
439
|
const fromTypes = defined
|
|
321
440
|
.filter((d) => !composed.has(d.name))
|
|
@@ -325,12 +444,25 @@ export async function takeCensus(root, opts) {
|
|
|
325
444
|
files: 0,
|
|
326
445
|
props: {
|
|
327
446
|
...Object.fromEntries(Object.entries(d.axes)),
|
|
447
|
+
...Object.fromEntries(Object.entries(variantAxes.get(d.name) ?? {})),
|
|
328
448
|
...Object.fromEntries(d.flags.map((f) => [f, ["true"]])),
|
|
329
449
|
},
|
|
330
450
|
propFiles: {},
|
|
331
451
|
// Their own rung, off the folder. It travels from the DEFINITION because
|
|
332
452
|
// that is the only reading that knows which file the component lives in.
|
|
333
453
|
...(d.tier ? { tier: d.tier } : {}),
|
|
454
|
+
/**
|
|
455
|
+
* The axes, kept BESIDE `props` as well as in it.
|
|
456
|
+
*
|
|
457
|
+
* A library composes nothing, so its components arrive through this branch and
|
|
458
|
+
* `declaredAxes` is where the contract writer looks. Leaving it out meant a
|
|
459
|
+
* Button whose `cva` declares nine variants reported none of them (probe,
|
|
460
|
+
* 01/08) - the styles reached the recipe and the axis names did not.
|
|
461
|
+
*/
|
|
462
|
+
...(Object.keys({ ...d.axes, ...(variantAxes.get(d.name) ?? {}) })
|
|
463
|
+
.length > 0
|
|
464
|
+
? { declaredAxes: { ...d.axes, ...(variantAxes.get(d.name) ?? {}) } }
|
|
465
|
+
: {}),
|
|
334
466
|
}));
|
|
335
467
|
/**
|
|
336
468
|
* A composed component that is ALSO defined keeps its usage and gains the
|
|
@@ -352,13 +484,22 @@ export async function takeCensus(root, opts) {
|
|
|
352
484
|
const declaredBy = new Map(defined.map((d) => [d.name, d]));
|
|
353
485
|
const enriched = inventory.map((c) => {
|
|
354
486
|
const d = declaredBy.get(c.name);
|
|
355
|
-
|
|
487
|
+
const fromVariants = variantAxes.get(c.name);
|
|
488
|
+
if (!d && !fromVariants)
|
|
356
489
|
return c;
|
|
357
|
-
const tier = d
|
|
358
|
-
|
|
490
|
+
const tier = d?.tier ? { tier: d.tier } : {};
|
|
491
|
+
/**
|
|
492
|
+
* THREE SOURCES FOR AN AXIS, and this is their order.
|
|
493
|
+
*
|
|
494
|
+
* The `cva` definition is the strongest: it declares the option AND what the
|
|
495
|
+
* option does. The prop type is next - it declares the closed set. Usage is last,
|
|
496
|
+
* because it shows what somebody happened to pick.
|
|
497
|
+
*/
|
|
498
|
+
const axes = { ...(d?.axes ?? {}), ...(fromVariants ?? {}) };
|
|
499
|
+
if (Object.keys(axes).length === 0) {
|
|
359
500
|
return Object.keys(tier).length > 0 ? { ...c, ...tier } : c;
|
|
360
501
|
}
|
|
361
|
-
return { ...c, declaredAxes:
|
|
502
|
+
return { ...c, declaredAxes: axes, ...tier };
|
|
362
503
|
});
|
|
363
504
|
const scoped = [...enriched, ...fromTypes];
|
|
364
505
|
/**
|
|
@@ -436,6 +577,37 @@ export async function takeCensus(root, opts) {
|
|
|
436
577
|
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
578
|
}
|
|
438
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* WHAT THE GATE TURNED AWAY - said out loud, grouped, with the reason.
|
|
582
|
+
*
|
|
583
|
+
* The sentence a person needs when a number drops by an order of magnitude: enough
|
|
584
|
+
* to trust it, and enough to notice if it dropped too far.
|
|
585
|
+
*/
|
|
586
|
+
/**
|
|
587
|
+
* HOW THE PROJECT IS ORGANISED - and the choice it leaves open, when there is one.
|
|
588
|
+
*
|
|
589
|
+
* The skill asks which has priority; the chosen one becomes a system-wide rule that
|
|
590
|
+
* reaches CLAUDE.md, so every prompt after this respects where a new component goes.
|
|
591
|
+
*/
|
|
592
|
+
if (architectures.length > 0) {
|
|
593
|
+
console.log("");
|
|
594
|
+
console.log(section("How this project is organised"));
|
|
595
|
+
const choice = describeChoice(architectures);
|
|
596
|
+
if (choice)
|
|
597
|
+
console.log(body(choice));
|
|
598
|
+
for (const a of architectures.slice(0, 4)) {
|
|
599
|
+
console.log(body(paint.faint(` ${describeArchitecture(a)}`)));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (skips.length > 0) {
|
|
603
|
+
console.log("");
|
|
604
|
+
console.log(body(describeGate(scoped.length, skips.length)));
|
|
605
|
+
for (const group of groupSkips(skips)) {
|
|
606
|
+
const shown = group.names.slice(0, 5);
|
|
607
|
+
const more = group.names.length - shown.length;
|
|
608
|
+
console.log(body(paint.faint(` ${group.names.length} ${group.label}: ${shown.join(", ")}${more > 0 ? `, and ${more} more` : ""}`)));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
439
611
|
if (library) {
|
|
440
612
|
console.log("");
|
|
441
613
|
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.`));
|
|
@@ -452,6 +624,26 @@ export async function takeCensus(root, opts) {
|
|
|
452
624
|
for (const r of observedRules(merged)) {
|
|
453
625
|
laws.set(r.component, [...(laws.get(r.component) ?? []), r.text]);
|
|
454
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* THE OWNER'S STEP FIVE, in the recipe's own `usage`.
|
|
629
|
+
*
|
|
630
|
+
* Whether a component forwards the rest of its props is the answer to "can I put an
|
|
631
|
+
* aria-label on it", and it is never in a type. And which components appear INSIDE it
|
|
632
|
+
* at its call sites is the composition half of a library's story - empty by
|
|
633
|
+
* construction until the app is read, because a library composes nothing internally.
|
|
634
|
+
*/
|
|
635
|
+
const systemOwns = new Set(merged.filter((c) => !c.from).map((c) => c.name));
|
|
636
|
+
for (const c of merged) {
|
|
637
|
+
const read = propsOf.get(c.name);
|
|
638
|
+
if (!read)
|
|
639
|
+
continue;
|
|
640
|
+
for (const rule of propRules(c.name, read)) {
|
|
641
|
+
laws.set(c.name, [...(laws.get(c.name) ?? []), rule.text]);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
for (const pair of nestingRules(nesting, systemOwns)) {
|
|
645
|
+
laws.set(pair.component, [...(laws.get(pair.component) ?? []), pair.text]);
|
|
646
|
+
}
|
|
455
647
|
const components = merged.map((c) => ({
|
|
456
648
|
...c,
|
|
457
649
|
...(verdicts.get(c.name) ?? {}),
|
|
@@ -491,6 +683,33 @@ export async function takeCensus(root, opts) {
|
|
|
491
683
|
observed: distinctValues(d),
|
|
492
684
|
...(components.length > 0 ? { components } : {}),
|
|
493
685
|
...(defined.length > 0 ? { defined } : {}),
|
|
686
|
+
/**
|
|
687
|
+
* THE SHAPE, AND THE RULE IT BECOMES.
|
|
688
|
+
*
|
|
689
|
+
* `rule` travels with each one so the platform writes it without owning a second
|
|
690
|
+
* copy of the sentence. The reader picks which architecture has PRIORITY; absent a
|
|
691
|
+
* choice the first is the one, which is the shape holding the most code.
|
|
692
|
+
*
|
|
693
|
+
* Carried rather than only printed: the whole point of the feature is that the
|
|
694
|
+
* chosen shape reaches CLAUDE.md, and a version of this that detected the shape,
|
|
695
|
+
* printed it, and never turned it into a rule is a feature that does nothing (dono,
|
|
696
|
+
* 01/08).
|
|
697
|
+
*/
|
|
698
|
+
...(architectures.length > 0
|
|
699
|
+
? {
|
|
700
|
+
architectures: architectures.slice(0, 6).map((a) => ({
|
|
701
|
+
...a,
|
|
702
|
+
rule: architectureRule(a),
|
|
703
|
+
})),
|
|
704
|
+
}
|
|
705
|
+
: {}),
|
|
706
|
+
...(skips.length > 0
|
|
707
|
+
? {
|
|
708
|
+
skipped: skips
|
|
709
|
+
.slice(0, 400)
|
|
710
|
+
.map(({ name, why, because }) => ({ name, why, because })),
|
|
711
|
+
}
|
|
712
|
+
: {}),
|
|
494
713
|
...((opts?.usage ?? []).length > 0
|
|
495
714
|
? { usage: (opts?.usage ?? []).map((u) => u.label) }
|
|
496
715
|
: {}),
|
|
@@ -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
|
+
}
|