synthesisui 0.16.78 → 0.16.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -207,7 +207,19 @@ const colorKey = (v) => {
207
207
  return `series-${series}`;
208
208
  return null;
209
209
  };
210
- /** "{typography.scale.sm.fontSize}" → "sm" (the --text-<key> utility). */
210
+ /**
211
+ * "{typography.scale.sm.fontSize}" → "sm" (the --text-<key> utility).
212
+ *
213
+ * A STEP maps to a utility; a ROLE deliberately does not.
214
+ *
215
+ * `typography.scale.<step>` names a step the system actually has, and a step it
216
+ * got from THEIR declaration is bridged into Tailwind's namespace - so `text-h1`
217
+ * exists and is the prettier output. `typography.role.<slot>` is one of our seven
218
+ * slots, and the bridge only ever claims a name they gave us: `text-base` may not
219
+ * exist in an imported system at all. So a role falls through to
220
+ * `var(--ds-typography-role-base-font-size)`, which is always emitted and can
221
+ * never dangle.
222
+ */
211
223
  const scaleKey = (v) => {
212
224
  const m = v.match(/^\{typography\.scale\.([a-zA-Z0-9-]+)\.fontSize\}$/);
213
225
  return m ? kebab(m[1]) : null;
@@ -504,6 +516,7 @@ const FORM_TAG = {
504
516
  icon: { tag: "span" },
505
517
  row: { tag: "div" },
506
518
  stack: { tag: "div" },
519
+ slot: { tag: "div" },
507
520
  };
508
521
  /** Only these two arrange; the rest are leaves. */
509
522
  const ARRANGES = new Set(["row", "stack"]);
@@ -543,6 +556,16 @@ function emitTree(nodes, comp, indent) {
543
556
  lines.push(`${indent}{/* ${node.from} renders here - a third party's component, so its markup is theirs */}`);
544
557
  continue;
545
558
  }
559
+ // The caller's content, so the generated code takes it as a prop rather than
560
+ // inventing a sample. A slot that also carries styles keeps its part element
561
+ // around the children; a bare one is just `{children}`.
562
+ if (node.as === "slot") {
563
+ const inner = `{children}${node.expects ? ` {/* ${node.expects} */}` : ""}`;
564
+ lines.push(node.part
565
+ ? `${indent}<${comp}${pascal(node.part)}>${inner}</${comp}${pascal(node.part)}>`
566
+ : `${indent}${inner}`);
567
+ continue;
568
+ }
546
569
  if (!node.part) {
547
570
  // Pure structure with no styles of its own: keep the arrangement, skip the
548
571
  // element - a div that carries nothing is a div nobody needs.
@@ -640,11 +663,13 @@ function partTagFor(part, form) {
640
663
  voidEl: Boolean(mapped.voidEl),
641
664
  };
642
665
  }
643
- function emitCssMode(slug, name, recipe, version, props, convention) {
666
+ function emitCssMode(slug, name, recipe, version, props, convention,
667
+ /** The name it takes in THEIR project. The class stays the system's. */
668
+ localName = name) {
644
669
  const { tag, attrs, voidEl } = elementFor(name, recipe);
645
670
  const el = asElement(tag, attrs, voidEl);
646
671
  const axes = axesOf(recipe.variants);
647
- const comp = pascal(name);
672
+ const comp = pascal(localName);
648
673
  const propNames = axes.map((a) => a.prop);
649
674
  const tree = recipe.preview?.parts ?? [];
650
675
  // With a shape the root RENDERS its children, so it needs `children` in the
@@ -713,7 +738,7 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
713
738
  });
714
739
  const needsElementType = el.offersAs || Object.keys(recipe.parts ?? {}).length > 0;
715
740
  return `${header(slug, name, version, "css")}
716
- import "./${name}.css";
741
+ import "./${localName}.css";
717
742
 
718
743
  import type { ${needsElementType ? `ElementType, ${props}` : props} } from "react";
719
744
 
@@ -727,11 +752,13 @@ ${rootJsx}
727
752
  }
728
753
  ${parts.filter(Boolean).join("\n")}`;
729
754
  }
730
- function emitTailwindMode(slug, name, recipe, version, props) {
755
+ function emitTailwindMode(slug, name, recipe, version, props,
756
+ /** The name it takes in THEIR project. The utilities stay the system's. */
757
+ localName = name) {
731
758
  const { tag, attrs, voidEl } = elementFor(name, recipe);
732
759
  const el = asElement(tag, attrs, voidEl);
733
760
  const axes = axesOf(recipe.variants);
734
- const comp = pascal(name);
761
+ const comp = pascal(localName);
735
762
  const variantConsts = axes
736
763
  .filter((a) => !a.boolean)
737
764
  .map((a) => {
@@ -878,27 +905,37 @@ reactMajor = null,
878
905
  * every caller that predates the convention being the user's - and getting this
879
906
  * wrong shipped a component wearing classes its own stylesheet never emits.
880
907
  */
881
- convention = DEFAULT_CONVENTION) {
882
- const comp = pascal(name);
908
+ convention = DEFAULT_CONVENTION,
909
+ /**
910
+ * THE NAME IT TAKES IN THEIR PROJECT, when it cannot take its own.
911
+ *
912
+ * A project that already exports `Button` should not have to give the name up to
913
+ * install ours - we adapt to what they built. So the FILE and the EXPORT can be
914
+ * renamed while the CLASS stays the design system's: the recipe compiles
915
+ * `.ds-button`, and a `<MySystemButton>` wearing it is styled correctly and
916
+ * shadows nothing of theirs.
917
+ *
918
+ * Absent means the component keeps its own name, which is every caller today.
919
+ */
920
+ localName = name) {
883
921
  const files = [];
884
922
  const props = propsTypeName(reactMajor);
885
923
  if (styles === "css") {
886
924
  files.push({
887
- filename: `${name}.tsx`,
888
- code: `${emitCssMode(slug, name, recipe, version, props, convention)}\n`,
925
+ filename: `${localName}.tsx`,
926
+ code: `${emitCssMode(slug, name, recipe, version, props, convention, localName)}\n`,
889
927
  });
890
- files.push({ filename: `${name}.css`, code: `${css}\n` });
928
+ files.push({ filename: `${localName}.css`, code: `${css}\n` });
891
929
  }
892
930
  else {
893
931
  files.push({
894
- filename: `${name}.tsx`,
895
- code: `${emitTailwindMode(slug, name, recipe, version, props)}\n`,
932
+ filename: `${localName}.tsx`,
933
+ code: `${emitTailwindMode(slug, name, recipe, version, props, localName)}\n`,
896
934
  });
897
935
  }
898
936
  files.push({
899
937
  filename: "index.ts",
900
- code: `export * from "./${name}";\n`,
938
+ code: `export * from "./${localName}";\n`,
901
939
  });
902
- void comp;
903
940
  return files;
904
941
  }
@@ -13,17 +13,30 @@ const LITERAL_PROP = /([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*\{?\s*["']([^"']*)["']\s*\}
13
13
  /** `dense` with no value is a boolean prop set to true, and that IS a literal
14
14
  * decision worth counting. */
15
15
  const BARE_PROP = /(^|\s)([a-z][a-zA-Z0-9_]*)(?=\s|$)/g;
16
+ /**
17
+ * A KEYWORD IS NOT AN IDENTIFIER, and the difference decides whether a `<` opens
18
+ * an element. `return <Card>one</Card>` is a component; `useState<Foo>()` is a
19
+ * type argument. Both are preceded by a word.
20
+ *
21
+ * Prettier writes `return <Foo />;` on one line whenever it fits, so without this
22
+ * every short component written that way read as a generic and vanished from the
23
+ * inventory - which is how a spec for something else caught it.
24
+ */
25
+ const JSX_BEFORE = /\b(return|case|default|await|yield|typeof|in|of|do|else|new)$/;
16
26
  /**
17
27
  * A generic in type position (`useState<Foo>()`, `Array<Item>`) is not an
18
28
  * element. The tell is what precedes the `<`: an identifier or a closing paren
19
- * means a type argument, while JSX follows `(`, `{`, `>`, a newline or nothing.
29
+ * means a type argument, while JSX follows `(`, `{`, `>`, a newline, a keyword or
30
+ * nothing.
20
31
  */
21
32
  function looksLikeType(source, at) {
22
33
  for (let i = at - 1; i >= 0; i--) {
23
34
  const c = source[i];
24
35
  if (c === " " || c === "\t")
25
36
  continue;
26
- return /[A-Za-z0-9_)\]]/.test(c);
37
+ if (!/[A-Za-z0-9_)\]]/.test(c))
38
+ return false;
39
+ return !JSX_BEFORE.test(source.slice(Math.max(0, i - 11), i + 1));
27
40
  }
28
41
  return false;
29
42
  }
@@ -81,6 +94,56 @@ function importedNames(source, internal = []) {
81
94
  }
82
95
  return out;
83
96
  }
97
+ /**
98
+ * Capitalised names a file BINDS ITSELF and does not export.
99
+ *
100
+ * `<Component>` and `<CustomLegend/>` both arrived in a real library's inventory as
101
+ * components of the system (dono, 01/08). Neither is one:
102
+ *
103
+ * function Text({ as: Component = "p" }) // React's polymorphic idiom -
104
+ * // `Component` is a prop rename
105
+ * const CustomLegend = () => (…) // a closure inside LineChart
106
+ *
107
+ * Capitalisation is React's rule for "not an html tag", which is not the same
108
+ * question as "is this a component the system offers". The test that separates them
109
+ * is REACHABILITY: a component of the system is either imported from somewhere or
110
+ * exported to somewhere. A name that only exists inside one function body is that
111
+ * function's private wiring, and giving it a recipe invents a component nobody can
112
+ * import.
113
+ *
114
+ * Local AND exported stays, of course - that is just a component defined where it
115
+ * is used.
116
+ */
117
+ const BOUND = /(?:const|let|var|function|class)\s+([A-Z][A-Za-z0-9_]*)|[{,]\s*\w+\s*:\s*([A-Z][A-Za-z0-9_]*)\s*(?:=[^,}]*)?\s*[,}]/g;
118
+ const EXPORTED_NAME = /export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Z][A-Za-z0-9_]*)|export\s+default\s+([A-Z][A-Za-z0-9_]*)|export\s*\{([^}]*)\}/g;
119
+ export function localOnlyNames(source) {
120
+ const exported = new Set();
121
+ EXPORTED_NAME.lastIndex = 0;
122
+ for (const m of source.matchAll(EXPORTED_NAME)) {
123
+ if (m[1])
124
+ exported.add(m[1]);
125
+ if (m[2])
126
+ exported.add(m[2]);
127
+ if (m[3]) {
128
+ for (const raw of m[3].split(",")) {
129
+ const name = raw
130
+ .trim()
131
+ .split(/\s+as\s+/)[0]
132
+ ?.trim();
133
+ if (name && /^[A-Z]/.test(name))
134
+ exported.add(name);
135
+ }
136
+ }
137
+ }
138
+ const out = new Set();
139
+ BOUND.lastIndex = 0;
140
+ for (const m of source.matchAll(BOUND)) {
141
+ const name = m[1] ?? m[2];
142
+ if (name && !exported.has(name))
143
+ out.add(name);
144
+ }
145
+ return out;
146
+ }
84
147
  export function emptyTally() {
85
148
  return new Map();
86
149
  }
@@ -92,11 +155,16 @@ internal = []) {
92
155
  if (!/\.(tsx|jsx|vue|svelte)$/i.test(file))
93
156
  return;
94
157
  const owners = importedNames(source, internal);
158
+ const private_ = localOnlyNames(source);
95
159
  ELEMENT.lastIndex = 0;
96
160
  for (const m of source.matchAll(ELEMENT)) {
97
161
  if (m.index != null && looksLikeType(source, m.index))
98
162
  continue;
99
163
  const name = m[1];
164
+ // Private wiring, not a component of the system. An import wins: a file may
165
+ // shadow nothing and still bind a name it also imports.
166
+ if (!owners.has(name) && private_.has(name.split(".")[0]))
167
+ continue;
100
168
  const body = m[2] ?? "";
101
169
  let hit = tally.get(name);
102
170
  if (!hit) {
@@ -304,8 +304,45 @@ function sharedMeaning(a, b) {
304
304
  n += 1;
305
305
  return n;
306
306
  }
307
- export function crosswalk(components) {
307
+ /**
308
+ * WHEN THE SOURCE IS THE LIBRARY, EVERY COMPONENT IS THEIRS.
309
+ *
310
+ * The crosswalk was designed for an APP that uses components: "your Pill reads as
311
+ * our badge, use ours" is exactly right there, because it stops a duplicate. When the
312
+ * source IS the component library, the premise inverts - their library IS the design
313
+ * system, and mapping their component onto ours is us overwriting theirs with ours.
314
+ * The opposite of entering as a complement.
315
+ *
316
+ * Measured on a real library (dono, 01/08): of 37 components they export, 16 never
317
+ * became theirs. `Label`, `Pill` and `Tag` - three distinct components - collapsed
318
+ * into one `ds-badge` of ours, which is why their Tag previewed as the word "Pill":
319
+ * that is OUR recipe's sample text. `LineChart`, a real organism, simply was not in
320
+ * the system.
321
+ *
322
+ * The code already knew. `describeOrigin` says it in prose - "declared in
323
+ * `packages/ui` and composed nowhere inside it, which is what a component library
324
+ * looks like from the inside" - and then acted as if it were an app.
325
+ *
326
+ * THE SIGNAL, and it is not a guess: a library declares and does not compose itself.
327
+ * When most of what a scope declares is composed nowhere inside it, the scope is a
328
+ * library. The crosswalk still runs and its verdict still travels as INFORMATION -
329
+ * "this reads as our badge" is worth knowing - it just stops replacing anything.
330
+ */
331
+ export function isLibrary(components) {
332
+ const mine = components.filter((c) => !c.from);
333
+ if (mine.length < 4)
334
+ return false;
335
+ const uncomposed = mine.filter((c) => (c.count ?? 0) === 0).length;
336
+ return uncomposed / mine.length >= 0.5;
337
+ }
338
+ export function crosswalk(components,
339
+ /**
340
+ * Their library is the system. Every component keeps its own recipe, and the
341
+ * catalogue match becomes a note rather than a substitution.
342
+ */
343
+ opts) {
308
344
  const mine = components.filter((c) => !c.from);
345
+ const library = opts?.library ?? false;
309
346
  // Two of THEIR components reading as the same catalogue entry is the
310
347
  // strongest redundancy signal there is, and it needs no threshold: it is not
311
348
  // "these look alike", it is "you wrote this twice". The first run filed both
@@ -344,6 +381,24 @@ export function crosswalk(components) {
344
381
  .sort((a, b) => b.overlap - a.overlap);
345
382
  if (canonical && CATALOGUE[canonical]) {
346
383
  const alsoClaiming = (claims.get(canonical) ?? []).filter((n) => n !== component.name);
384
+ /**
385
+ * LIBRARY MODE: the match is a NOTE, not a verdict.
386
+ *
387
+ * Their component stays exclusive and keeps its own recipe; `canonical` still
388
+ * travels so a preview can resolve a frontier and so the v2 can say "these
389
+ * three read alike". What stops happening is their `Label` ceasing to exist.
390
+ */
391
+ if (library) {
392
+ return {
393
+ component,
394
+ canonical,
395
+ twins,
396
+ bucket: "exclusive",
397
+ because: alsoClaiming.length > 0
398
+ ? `yours, and it reads like ds-${canonical} - so do ${alsoClaiming.join(", ")}, which is one decision written ${alsoClaiming.length + 1} times`
399
+ : `yours, and it reads like ds-${canonical} - kept as yours because this library IS the system`,
400
+ };
401
+ }
347
402
  if (alsoClaiming.length > 0) {
348
403
  return {
349
404
  component,
@@ -369,7 +424,9 @@ export function crosswalk(components) {
369
424
  component,
370
425
  canonical,
371
426
  twins,
372
- bucket: "nearly",
427
+ // A twin is a v2 observation either way; in library mode it must not cost
428
+ // the component its own recipe.
429
+ bucket: library ? "exclusive" : "nearly",
373
430
  because: `shares ${Math.round(twins[0].overlap * 100)}% of its options with ${twins[0].name} - one decision, two components`,
374
431
  };
375
432
  }
@@ -20,6 +20,36 @@
20
20
  * actually write and stays silent on the rest: a props type built by `Omit<>` or
21
21
  * spread from another interface yields no axes rather than wrong ones.
22
22
  */
23
+ /**
24
+ * THEIR OWN LADDER, READ OFF THE PATH - never guessed from a name.
25
+ *
26
+ * A real library keeps `atoms/Text/Text.tsx`, `molecules/MetricCard/`,
27
+ * `organisms/DataTable/`: the author saying out loud what composes what, in the
28
+ * vocabulary the whole industry already shares. Twenty-three components arrived in
29
+ * one flat bucket and the information was sitting in the path (dono, 01/08).
30
+ *
31
+ * A path segment, not a substring: `src/atomsphere/` is a folder called
32
+ * atomsphere. Returns undefined for the projects that do not organise themselves
33
+ * this way, which is most of them - and undefined means "no ladder here", not
34
+ * "unclassified".
35
+ */
36
+ const TIER_FOLDER = {
37
+ atoms: "atom",
38
+ atom: "atom",
39
+ primitives: "atom",
40
+ molecules: "molecule",
41
+ molecule: "molecule",
42
+ organisms: "organism",
43
+ organism: "organism",
44
+ };
45
+ export function tierOf(file) {
46
+ for (const segment of file.split(/[/\\]/)) {
47
+ const tier = TIER_FOLDER[segment.toLowerCase()];
48
+ if (tier)
49
+ return tier;
50
+ }
51
+ return undefined;
52
+ }
23
53
  /** `export function X`, `export default function X`, `export const X =`. */
24
54
  const EXPORTED = /export\s+(?:default\s+)?(?:async\s+)?(?:function\s+([A-Z][A-Za-z0-9_]*)|const\s+([A-Z][A-Za-z0-9_]*)\s*[:=])/g;
25
55
  /** `type ButtonProps = { … }` or `interface ButtonProps { … }`, to its closing
@@ -78,11 +108,13 @@ export function scanDefinitions(file, source) {
78
108
  continue;
79
109
  seen.add(name);
80
110
  const props = propsOf.get(name);
111
+ const tier = tierOf(file);
81
112
  out.push({
82
113
  name,
83
114
  file,
84
115
  axes: props?.axes ?? {},
85
116
  flags: props?.flags ?? [],
117
+ ...(tier ? { tier } : {}),
86
118
  });
87
119
  }
88
120
  return out;
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ Usage - deterministic, FREE:
41
41
 
42
42
  Usage - governance (deterministic, FREE):
43
43
  synthesisui adopt [--write] turn the design system you ALREADY have into a contract
44
- synthesisui import [--dry] [--scope <p>] read the app you already have and make it a system
44
+ synthesisui import [--scope <p>] [--usage <p>] read what you already have and make it a system
45
45
  your agent follows - without touching your CSS
46
46
  synthesisui connect wire your agent: the check as an editor hook, the system
47
47
  as MCP tools, and a contract that stops repeating itself
@@ -66,7 +66,8 @@ Options:
66
66
  --version <n> install a specific version (default: latest)
67
67
  --ds <slug> init: bring this DS in right away · generate: target DS (default: installed)
68
68
  --name <name> preferred component name for generate
69
- --scope <path> import: measure only this folder; the census still lands at the root
69
+ --scope <path> import: the SYSTEM - tokens, components, convention. One folder.
70
+ --usage <path> import: the EVIDENCE - counts, chosen values, laws. Repeatable.
70
71
  --scheme <s> dark|light - which end of your ladder is the default
71
72
  --target <t> template/init target: next | general (default: next)
72
73
  --pages-dir <dir> init: folder for generated pages (default: app)
@@ -111,9 +112,30 @@ Examples:
111
112
  synthesisui generate "an upgrade banner with a title, message and a primary CTA"
112
113
  `;
113
114
  /** Extracts simple `--flag value` pairs and the remaining positionals. */
115
+ /**
116
+ * A REPEATED FLAG ACCUMULATES rather than overwriting.
117
+ *
118
+ * `--usage apps/web --usage apps/admin` is the natural way to say two, and keeping
119
+ * only the last would drop one silently - which is the failure mode this codebase
120
+ * refuses everywhere else. Every existing flag is passed once and still arrives as
121
+ * a string, so nothing that reads `typeof flags.x === "string"` changes.
122
+ */
114
123
  function parseFlags(argv) {
115
124
  const positionals = [];
116
125
  const flags = {};
126
+ const put = (key, value) => {
127
+ const had = flags[key];
128
+ if (had === undefined) {
129
+ flags[key] = value;
130
+ return;
131
+ }
132
+ if (typeof value !== "string")
133
+ return;
134
+ flags[key] = [
135
+ ...(Array.isArray(had) ? had : typeof had === "string" ? [had] : []),
136
+ value,
137
+ ];
138
+ };
117
139
  for (let i = 0; i < argv.length; i++) {
118
140
  const arg = argv[i];
119
141
  if (arg === "-h" || arg === "--help") {
@@ -124,17 +146,17 @@ function parseFlags(argv) {
124
146
  const eq = body.indexOf("=");
125
147
  if (eq !== -1) {
126
148
  // `--key=value` form (e.g. --out=templates/page.tsx)
127
- flags[body.slice(0, eq)] = body.slice(eq + 1);
149
+ put(body.slice(0, eq), body.slice(eq + 1));
128
150
  }
129
151
  else {
130
152
  // `--key value` form
131
153
  const next = argv[i + 1];
132
154
  if (next !== undefined && !next.startsWith("--")) {
133
- flags[body] = next;
155
+ put(body, next);
134
156
  i++;
135
157
  }
136
158
  else {
137
- flags[body] = true;
159
+ put(body, true);
138
160
  }
139
161
  }
140
162
  }
@@ -165,6 +187,14 @@ async function main() {
165
187
  // invert a system, so it falls through to being measured and asked.
166
188
  // WHAT to read. `--dir` stays WHERE the project is, in every command.
167
189
  scope: typeof flags.scope === "string" ? flags.scope : undefined,
190
+ // WHERE THE EVIDENCE IS. Repeatable, and comma-separated works too - a
191
+ // person typing one path with a comma in it means two paths.
192
+ usage: [flags.usage]
193
+ .flat()
194
+ .filter((v) => typeof v === "string")
195
+ .flatMap((v) => v.split(","))
196
+ .map((v) => v.trim())
197
+ .filter(Boolean),
168
198
  scheme: flags.scheme === "dark" || flags.scheme === "light"
169
199
  ? flags.scheme
170
200
  : undefined,
@@ -13,8 +13,8 @@
13
13
  * handed somebody a React component wearing classes their own stylesheet never
14
14
  * emits. It renders completely unstyled, and nothing anywhere reports a problem.
15
15
  */
16
- import { readFile } from "node:fs/promises";
17
- import { join } from "node:path";
16
+ import { readdir, readFile } from "node:fs/promises";
17
+ import { join, relative } from "node:path";
18
18
  import { DEFAULT_CONVENTION, } from "./component-codegen.js";
19
19
  /**
20
20
  * Which React the consumer is on, for the ref-carrying prop type.
@@ -38,6 +38,62 @@ export async function reactMajorOf(root) {
38
38
  return null;
39
39
  }
40
40
  }
41
+ export async function findCollision(root, componentsDir, name, pascal) {
42
+ const target = join(root, componentsDir, name, `${name}.tsx`);
43
+ const head = await readFile(target, "utf8")
44
+ .then((t) => t.slice(0, 300))
45
+ .catch(() => null);
46
+ const ours = head?.includes("Generated by SynthesisUI") ?? false;
47
+ /**
48
+ * Their own export of that name, searched where a component library lives.
49
+ *
50
+ * Deliberately shallow: `src`, `app`, `components`, `packages` and the
51
+ * configured folder, skipping anything generated. A deep walk of a monorepo to
52
+ * answer one question is not worth the seconds.
53
+ */
54
+ const pattern = new RegExp(`export\\s+(?:default\\s+)?(?:function|const|class)\\s+${pascal}\\b`);
55
+ const roots = [
56
+ componentsDir,
57
+ "src/components",
58
+ "components",
59
+ "src/ui",
60
+ "packages",
61
+ ];
62
+ let exported = null;
63
+ for (const rel of roots) {
64
+ if (exported)
65
+ break;
66
+ for await (const file of walkShallow(join(root, rel), 3)) {
67
+ if (!/\.(tsx|jsx|ts)$/.test(file))
68
+ continue;
69
+ if (file === target)
70
+ continue;
71
+ const source = await readFile(file, "utf8").catch(() => "");
72
+ if (source.includes("Generated by SynthesisUI"))
73
+ continue;
74
+ if (pattern.test(source)) {
75
+ exported = relative(root, file);
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ return { file: head == null ? null : relative(root, target), exported, ours };
81
+ }
82
+ /** Files under `dir`, at most `depth` levels down, skipping the usual noise. */
83
+ async function* walkShallow(dir, depth) {
84
+ if (depth < 0)
85
+ return;
86
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
87
+ for (const entry of entries) {
88
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
89
+ continue;
90
+ const full = join(dir, entry.name);
91
+ if (entry.isDirectory())
92
+ yield* walkShallow(full, depth - 1);
93
+ else
94
+ yield full;
95
+ }
96
+ }
41
97
  /** The pinned version of an installed system, from its `.lock`. */
42
98
  async function pinnedVersion(dir) {
43
99
  const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");