synthesisui 0.16.80 → 0.16.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { 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";
@@ -14,6 +17,7 @@ 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";
16
19
  import { body, paint, section } from "../output.js";
20
+ import { startProgress } from "../progress.js";
17
21
  import { detectStack, resolveDeps } from "../stack.js";
18
22
  import { walk, walkAll } from "./doctor.js";
19
23
  /**
@@ -216,6 +220,8 @@ export async function takeCensus(root, opts) {
216
220
  const tally = emptyTally();
217
221
  const internal = await internalSpecifiers(root);
218
222
  const defined = [];
223
+ /** What the gate turned away, with the reason. Never dropped in silence. */
224
+ const skips = [];
219
225
  // Kept for the reference check below, which needs every file at once: a name
220
226
  // is only broken if NOTHING declares it, anywhere.
221
227
  const sources = [];
@@ -227,11 +233,39 @@ export async function takeCensus(root, opts) {
227
233
  declaredValues.set(m[1], m[2].trim());
228
234
  }
229
235
  const looks = {};
236
+ /**
237
+ * The directory shape, gathered while the files are already being walked - so
238
+ * naming how the project is organised costs no second pass. See `architecture.ts`.
239
+ */
240
+ const dirs = new Map();
241
+ /** Parent → child → the files that put one inside the other. */
242
+ const nesting = new Map();
243
+ /** Component → what its own definition names and whether it forwards the rest. */
244
+ const propsOf = new Map();
245
+ /**
246
+ * A census over a real monorepo reads 2797 files, and the terminal said nothing for
247
+ * most of it. Twenty silent minutes is indistinguishable from twenty broken ones
248
+ * (dono, 01/08).
249
+ */
250
+ const progress = startProgress("Reading");
230
251
  for await (const file of walk(root)) {
231
252
  const src = await readFile(file, "utf8").catch(() => "");
232
253
  if (!src)
233
254
  continue;
234
255
  const rel = relative(root, file);
256
+ progress.step(rel);
257
+ if (/\.(tsx|jsx|vue|svelte)$/i.test(rel)) {
258
+ const parts = rel.split("/");
259
+ // Every ancestor learns the name of the child directly under it, which is all
260
+ // the shape detector needs and is one pass rather than a tree walk.
261
+ for (let i = 0; i < parts.length - 1; i++) {
262
+ const at = parts.slice(0, i).join("/");
263
+ const entry = dirs.get(at) ?? { dirs: new Set(), files: 0 };
264
+ entry.dirs.add(parts[i]);
265
+ entry.files += 1;
266
+ dirs.set(at, entry);
267
+ }
268
+ }
235
269
  sources.push({ file: rel, source: src });
236
270
  reports.push(scanSource(rel, src, table));
237
271
  // Stories and tests compose components to SHOW them; counting those as the
@@ -239,9 +273,39 @@ export async function takeCensus(root, opts) {
239
273
  // they were set aside.
240
274
  if (!/(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/.test(rel)) {
241
275
  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);
276
+ /**
277
+ * The other half: what this file EXPORTS, with the axes its types declare. A
278
+ * library composes almost nothing and exports everything.
279
+ *
280
+ * THROUGH THE GATE, which is the difference between 37 and 704. Every test in
281
+ * it is about CATEGORY - a route, a provider, a config object - never quality,
282
+ * and every rejection is carried with its reason.
283
+ */
284
+ // HOW IT IS USED, not only what it is: which props people pass, what it passes
285
+ // on, and what shows up inside it. See `call-sites.ts`.
286
+ readNesting(rel, src, nesting);
287
+ const found = [];
288
+ for (const d of scanDefinitions(rel, src)) {
289
+ const verdict = gateComponent({
290
+ name: d.name,
291
+ file: rel,
292
+ source: src,
293
+ internal,
294
+ });
295
+ if (verdict.ok) {
296
+ found.push(d);
297
+ continue;
298
+ }
299
+ skips.push({
300
+ name: d.name,
301
+ file: rel,
302
+ why: verdict.why,
303
+ because: verdict.because,
304
+ });
305
+ }
306
+ for (const d of found) {
307
+ propsOf.set(d.name, readDefinitionProps(d.name, src));
308
+ }
245
309
  defined.push(...found);
246
310
  // THE LOOK, from their own class names. One transcription per file, keyed
247
311
  // by the component it defines - the root element's classes are the
@@ -259,8 +323,14 @@ export async function takeCensus(root, opts) {
259
323
  }
260
324
  }
261
325
  }
326
+ progress.done();
327
+ const architectures = detectArchitectures([...dirs.entries()].map(([path, v]) => ({
328
+ path: path || ".",
329
+ dirs: [...v.dirs],
330
+ files: v.files,
331
+ })));
262
332
  const d = diagnose(reports);
263
- const inventory = tallyToInventory(tally);
333
+ const allComposed = tallyToInventory(tally, 400);
264
334
  /**
265
335
  * THE EVIDENCE PASS - a second walk that touches ONE reading.
266
336
  *
@@ -275,6 +345,7 @@ export async function takeCensus(root, opts) {
275
345
  */
276
346
  const usageTally = emptyTally();
277
347
  const usageSeen = new Set();
348
+ const usageProgress = (opts?.usage ?? []).length > 0 ? startProgress("Evidence") : null;
278
349
  /**
279
350
  * THE SCOPE'S OWN PACKAGE NAME IS INTERNAL, always.
280
351
  *
@@ -299,9 +370,11 @@ export async function takeCensus(root, opts) {
299
370
  continue;
300
371
  }
301
372
  usageSeen.add(rel);
373
+ usageProgress?.step(rel);
302
374
  scanComponentsInto(usageTally, rel, src, [...shared, ...uInternal]);
303
375
  }
304
376
  }
377
+ usageProgress?.done();
305
378
  const usedThere = tallyToInventory(usageTally, 400);
306
379
  /**
307
380
  * THE TWO READINGS BECOME ONE LIST before anything is judged.
@@ -316,6 +389,16 @@ export async function takeCensus(root, opts) {
316
389
  * is the better source anyway - usage shows what somebody picked, the
317
390
  * declaration shows what the author designed.
318
391
  */
392
+ /**
393
+ * THE GATE APPLIES FROM BOTH SIDES.
394
+ *
395
+ * A page reaches the inventory two ways: exported (caught above) and COMPOSED -
396
+ * `<AuthLayout>` wrapping a tree is a JSX tag like any other. Filtering only the
397
+ * definitions would have let it back in through the usage door under the same
398
+ * name, and the vitrine cannot tell which door a name came through.
399
+ */
400
+ const turnedAway = new Set(skips.map((s) => s.name));
401
+ const inventory = allComposed.filter((c) => !turnedAway.has(c.name));
319
402
  const composed = new Set(inventory.map((c) => c.name));
320
403
  const fromTypes = defined
321
404
  .filter((d) => !composed.has(d.name))
@@ -436,6 +519,37 @@ export async function takeCensus(root, opts) {
436
519
  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
520
  }
438
521
  }
522
+ /**
523
+ * WHAT THE GATE TURNED AWAY - said out loud, grouped, with the reason.
524
+ *
525
+ * The sentence a person needs when a number drops by an order of magnitude: enough
526
+ * to trust it, and enough to notice if it dropped too far.
527
+ */
528
+ /**
529
+ * HOW THE PROJECT IS ORGANISED - and the choice it leaves open, when there is one.
530
+ *
531
+ * The skill asks which has priority; the chosen one becomes a system-wide rule that
532
+ * reaches CLAUDE.md, so every prompt after this respects where a new component goes.
533
+ */
534
+ if (architectures.length > 0) {
535
+ console.log("");
536
+ console.log(section("How this project is organised"));
537
+ const choice = describeChoice(architectures);
538
+ if (choice)
539
+ console.log(body(choice));
540
+ for (const a of architectures.slice(0, 4)) {
541
+ console.log(body(paint.faint(` ${describeArchitecture(a)}`)));
542
+ }
543
+ }
544
+ if (skips.length > 0) {
545
+ console.log("");
546
+ console.log(body(describeGate(scoped.length, skips.length)));
547
+ for (const group of groupSkips(skips)) {
548
+ const shown = group.names.slice(0, 5);
549
+ const more = group.names.length - shown.length;
550
+ console.log(body(paint.faint(` ${group.names.length} ${group.label}: ${shown.join(", ")}${more > 0 ? `, and ${more} more` : ""}`)));
551
+ }
552
+ }
439
553
  if (library) {
440
554
  console.log("");
441
555
  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 +566,26 @@ export async function takeCensus(root, opts) {
452
566
  for (const r of observedRules(merged)) {
453
567
  laws.set(r.component, [...(laws.get(r.component) ?? []), r.text]);
454
568
  }
569
+ /**
570
+ * THE OWNER'S STEP FIVE, in the recipe's own `usage`.
571
+ *
572
+ * Whether a component forwards the rest of its props is the answer to "can I put an
573
+ * aria-label on it", and it is never in a type. And which components appear INSIDE it
574
+ * at its call sites is the composition half of a library's story - empty by
575
+ * construction until the app is read, because a library composes nothing internally.
576
+ */
577
+ const systemOwns = new Set(merged.filter((c) => !c.from).map((c) => c.name));
578
+ for (const c of merged) {
579
+ const read = propsOf.get(c.name);
580
+ if (!read)
581
+ continue;
582
+ for (const rule of propRules(c.name, read)) {
583
+ laws.set(c.name, [...(laws.get(c.name) ?? []), rule.text]);
584
+ }
585
+ }
586
+ for (const pair of nestingRules(nesting, systemOwns)) {
587
+ laws.set(pair.component, [...(laws.get(pair.component) ?? []), pair.text]);
588
+ }
455
589
  const components = merged.map((c) => ({
456
590
  ...c,
457
591
  ...(verdicts.get(c.name) ?? {}),
@@ -491,6 +625,16 @@ export async function takeCensus(root, opts) {
491
625
  observed: distinctValues(d),
492
626
  ...(components.length > 0 ? { components } : {}),
493
627
  ...(defined.length > 0 ? { defined } : {}),
628
+ ...(architectures.length > 0
629
+ ? { architectures: architectures.slice(0, 6) }
630
+ : {}),
631
+ ...(skips.length > 0
632
+ ? {
633
+ skipped: skips
634
+ .slice(0, 400)
635
+ .map(({ name, why, because }) => ({ name, why, because })),
636
+ }
637
+ : {}),
494
638
  ...((opts?.usage ?? []).length > 0
495
639
  ? { usage: (opts?.usage ?? []).map((u) => u.label) }
496
640
  : {}),
@@ -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
+ }