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.
@@ -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
- // The other half: what this file EXPORTS, with the axes its types
243
- // declare. A library composes almost nothing and exports everything.
244
- const found = scanDefinitions(rel, src);
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
- const size = Object.keys(t.base).length +
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(t.states).length;
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] = tag ? { ...t, rootTag: tag } : t;
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 inventory = tallyToInventory(tally);
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
- if (!d)
487
+ const fromVariants = variantAxes.get(c.name);
488
+ if (!d && !fromVariants)
356
489
  return c;
357
- const tier = d.tier ? { tier: d.tier } : {};
358
- if (Object.keys(d.axes).length === 0) {
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: d.axes, ...tier };
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
+ }