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.
- package/dist/commands/import.js +148 -4
- 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 -0
- 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 { 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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
+
}
|