onsystem 0.9.1 → 0.10.1

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.
@@ -7,7 +7,7 @@ import ts from "typescript";
7
7
  import os from "node:os";
8
8
  import postcss from "postcss";
9
9
  import { clampChroma, colorsNamed, converter, differenceEuclidean, formatHex, parse, wcagContrast } from "culori";
10
- import { createHash } from "node:crypto";
10
+ import { createHash, randomUUID } from "node:crypto";
11
11
  import fsp from "node:fs/promises";
12
12
  import { LineCounter, isMap, isScalar, parseDocument, stringify } from "yaml";
13
13
  import { stemmer } from "stemmer";
@@ -122,6 +122,74 @@ function isProjectFile(file) {
122
122
  return !/[\\/]node_modules[\\/]/.test(file);
123
123
  }
124
124
  //#endregion
125
+ //#region src/util/strings.ts
126
+ /** `alert-dialog` / `alert_dialog` / `alertDialog` → `AlertDialog`. */
127
+ function pascalCase(input) {
128
+ return input.replace(/\.[^.]+$/, "").split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join("");
129
+ }
130
+ /** `AlertDialog` → `["Alert", "Dialog"]`, `isHTMLSafe` → `["is", "HTML", "Safe"]`. */
131
+ function splitWords(input) {
132
+ return input.split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean);
133
+ }
134
+ function isPascalCase(name) {
135
+ return /^[A-Z][A-Za-z0-9]*$/.test(name);
136
+ }
137
+ /** Optimal string alignment distance (Levenshtein plus adjacent transpositions). */
138
+ function editDistance(a, b) {
139
+ if (a === b) return 0;
140
+ if (!a.length) return b.length;
141
+ if (!b.length) return a.length;
142
+ const rows = a.length + 1;
143
+ const cols = b.length + 1;
144
+ const d = new Array(rows * cols).fill(0);
145
+ const at = (i, j) => d[i * cols + j] ?? 0;
146
+ for (let i = 0; i < rows; i++) d[i * cols] = i;
147
+ for (let j = 0; j < cols; j++) d[j] = j;
148
+ for (let i = 1; i < rows; i++) for (let j = 1; j < cols; j++) {
149
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
150
+ let v = Math.min(at(i - 1, j) + 1, at(i, j - 1) + 1, at(i - 1, j - 1) + cost);
151
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, at(i - 2, j - 2) + 1);
152
+ d[i * cols + j] = v;
153
+ }
154
+ return at(a.length, b.length);
155
+ }
156
+ /**
157
+ * The candidate closest to `input` by case-insensitive edit distance, if it is
158
+ * close enough to be a plausible typo (at most a third of the length, min 1).
159
+ */
160
+ function closest(input, candidates, maxRatio = 1 / 3) {
161
+ const needle = input.toLowerCase();
162
+ const limit = Math.max(1, Math.floor(needle.length * maxRatio));
163
+ let best;
164
+ let bestScore = Infinity;
165
+ for (const candidate of candidates) {
166
+ const gap = Math.abs(candidate.length - needle.length);
167
+ if (gap > limit || gap >= bestScore) continue;
168
+ const score = editDistance(needle, candidate.toLowerCase());
169
+ if (score < bestScore) {
170
+ best = candidate;
171
+ bestScore = score;
172
+ }
173
+ }
174
+ return bestScore <= limit ? best : void 0;
175
+ }
176
+ function truncate(text, max) {
177
+ return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
178
+ }
179
+ /**
180
+ * `text` as a JSON string, with U+0085, U+2028 and U+2029 escaped too, so it stays on one line
181
+ * (JSON escapes the other line breaks).
182
+ */
183
+ function json(text) {
184
+ return JSON.stringify(text).replace(/[\u0085\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
185
+ }
186
+ function plural$1(count, word, pluralWord = `${word}s`) {
187
+ return `${count} ${count === 1 ? word : pluralWord}`;
188
+ }
189
+ function unique(items) {
190
+ return [...new Set(items)];
191
+ }
192
+ //#endregion
125
193
  //#region src/util/paths.ts
126
194
  function toPosix(p) {
127
195
  return p.split(path.sep).join("/");
@@ -271,6 +339,14 @@ function splitTopLevel$2(text) {
271
339
  function escapeRegExp(text) {
272
340
  return text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
273
341
  }
342
+ /**
343
+ * A file, folder or import path, as written when it holds only the characters of a path (letters,
344
+ * digits and `_ @ $ . + ~ / \ : ( ) [ ] { } # % , = ! -`, as in `app/(site)/[id]/page.tsx`);
345
+ * otherwise, as for a path that holds a space or a line break (git allows one), JSON-quoted.
346
+ */
347
+ function pathOf(file) {
348
+ return /^[\p{L}\p{N}_@$.+~/\\:()[\]{}#%,=!-]+$/u.test(file) ? file : json(file);
349
+ }
274
350
  //#endregion
275
351
  //#region src/package-source.ts
276
352
  /**
@@ -567,67 +643,6 @@ function importedStylesheets(files, resolve) {
567
643
  return out;
568
644
  }
569
645
  //#endregion
570
- //#region src/util/strings.ts
571
- /** `alert-dialog` / `alert_dialog` / `alertDialog` → `AlertDialog`. */
572
- function pascalCase(input) {
573
- return input.replace(/\.[^.]+$/, "").split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join("");
574
- }
575
- /** `AlertDialog` → `["Alert", "Dialog"]`, `isHTMLSafe` → `["is", "HTML", "Safe"]`. */
576
- function splitWords(input) {
577
- return input.split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean);
578
- }
579
- function isPascalCase(name) {
580
- return /^[A-Z][A-Za-z0-9]*$/.test(name);
581
- }
582
- /** Optimal string alignment distance (Levenshtein plus adjacent transpositions). */
583
- function editDistance(a, b) {
584
- if (a === b) return 0;
585
- if (!a.length) return b.length;
586
- if (!b.length) return a.length;
587
- const rows = a.length + 1;
588
- const cols = b.length + 1;
589
- const d = new Array(rows * cols).fill(0);
590
- const at = (i, j) => d[i * cols + j] ?? 0;
591
- for (let i = 0; i < rows; i++) d[i * cols] = i;
592
- for (let j = 0; j < cols; j++) d[j] = j;
593
- for (let i = 1; i < rows; i++) for (let j = 1; j < cols; j++) {
594
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
595
- let v = Math.min(at(i - 1, j) + 1, at(i, j - 1) + 1, at(i - 1, j - 1) + cost);
596
- if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, at(i - 2, j - 2) + 1);
597
- d[i * cols + j] = v;
598
- }
599
- return at(a.length, b.length);
600
- }
601
- /**
602
- * The candidate closest to `input` by case-insensitive edit distance, if it is
603
- * close enough to be a plausible typo (at most a third of the length, min 1).
604
- */
605
- function closest(input, candidates, maxRatio = 1 / 3) {
606
- const needle = input.toLowerCase();
607
- const limit = Math.max(1, Math.floor(needle.length * maxRatio));
608
- let best;
609
- let bestScore = Infinity;
610
- for (const candidate of candidates) {
611
- const gap = Math.abs(candidate.length - needle.length);
612
- if (gap > limit || gap >= bestScore) continue;
613
- const score = editDistance(needle, candidate.toLowerCase());
614
- if (score < bestScore) {
615
- best = candidate;
616
- bestScore = score;
617
- }
618
- }
619
- return bestScore <= limit ? best : void 0;
620
- }
621
- function truncate(text, max) {
622
- return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
623
- }
624
- function plural$1(count, word, pluralWord = `${word}s`) {
625
- return `${count} ${count === 1 ? word : pluralWord}`;
626
- }
627
- function unique(items) {
628
- return [...new Set(items)];
629
- }
630
- //#endregion
631
646
  //#region src/detect.ts
632
647
  const ignore = () => void 0;
633
648
  const COMPONENT_FILE = /\.[jt]sx$/;
@@ -1588,11 +1603,11 @@ const PACKAGE_FIELD = "onsystem";
1588
1603
  function declaredConfig(dir, pkg = readJson$1(path.join(dir, "package.json"))) {
1589
1604
  const value = pkg?.[PACKAGE_FIELD];
1590
1605
  if (value === void 0) return void 0;
1591
- const field = `"${PACKAGE_FIELD}" in ${path.join(dir, "package.json")}`;
1606
+ const field = `"${PACKAGE_FIELD}" in ${pathOf(path.join(dir, "package.json"))}`;
1592
1607
  if (typeof value !== "string" || !value.endsWith(".json")) return { problem: `${field} is ${JSON.stringify(value)}: it names a JSON config file in the package, such as "./onsystem.config.json".` };
1593
1608
  const file = path.resolve(dir, value);
1594
- if (!isInside(dir, file) || file === path.resolve(dir)) return { problem: `${field} names ${value}, which is outside the package.` };
1595
- if (!isFile(file)) return { problem: `${field} names ${value}, which does not exist.` };
1609
+ if (!isInside(dir, file) || file === path.resolve(dir)) return { problem: `${field} names ${JSON.stringify(value)}, which is outside the package.` };
1610
+ if (!isFile(file)) return { problem: `${field} names ${JSON.stringify(value)}, which does not exist.` };
1596
1611
  return { file };
1597
1612
  }
1598
1613
  /**
@@ -3530,7 +3545,7 @@ function unknownKeys(what, known) {
3530
3545
  if (issue.code !== "unrecognized_keys") return void 0;
3531
3546
  return issue.keys.map((key) => {
3532
3547
  const guess = closest(key, known, .5);
3533
- return `Unknown ${what} "${key}". ${guess ? `Did you mean "${guess}"?` : `Expected one of: ${known.join(", ")}.`}`;
3548
+ return `Unknown ${what} ${JSON.stringify(key)}. ${guess ? `Did you mean "${guess}"?` : `Expected one of: ${known.join(", ")}.`}`;
3534
3549
  }).join("\n");
3535
3550
  };
3536
3551
  }
@@ -3544,7 +3559,7 @@ const ruleSettingSchema = z.union([
3544
3559
  if (typeof issue.input !== "string") return void 0;
3545
3560
  const input = issue.input;
3546
3561
  const guess = SEVERITIES.find((s) => input && (s.startsWith(input) || input.startsWith(s)));
3547
- return `Invalid severity "${input}": use "off", "warn" or "error"${guess ? ` (did you mean "${guess}"?)` : ""}.`;
3562
+ return `Invalid severity ${JSON.stringify(input)}: use "off", "warn" or "error"${guess ? ` (did you mean "${guess}"?)` : ""}.`;
3548
3563
  } });
3549
3564
  const globsSchema = z.union([z.string(), z.array(z.string())]);
3550
3565
  const rulesSchema = z.partialRecord(z.enum(RULE_IDS), ruleSettingSchema, { error: unknownKeys("rule", RULE_IDS) });
@@ -3562,18 +3577,19 @@ const librariesSchema = z.union([z.literal(false), z.array(z.enum(LIBRARY_IDS))]
3562
3577
  const unknown = Array.isArray(issue.input) ? issue.input.find((id) => !LIBRARY_IDS.includes(id)) : void 0;
3563
3578
  if (typeof unknown === "string") {
3564
3579
  const guess = closest(unknown, LIBRARY_IDS, .5);
3565
- return `Unknown library "${unknown}". ${guess ? `Did you mean "${guess}"?` : `Expected one of: ${LIBRARY_IDS.join(", ")}.`}`;
3580
+ return `Unknown library ${JSON.stringify(unknown)}. ${guess ? `Did you mean "${guess}"?` : `Expected one of: ${LIBRARY_IDS.join(", ")}.`}`;
3566
3581
  }
3567
3582
  return `"libraries" is false, to read no library, or a list of the ones to read: ${JSON.stringify(LIBRARY_IDS)}.`;
3568
3583
  } });
3569
3584
  const configShape = {
3570
3585
  $schema: z.string().optional(),
3586
+ extends: z.boolean({ error: (issue) => `"extends" is true or false, not ${JSON.stringify(issue.input)}: the design-system package is found from the dependencies, the one whose package.json declares its config ("onsystem"). Leave "extends" out to build on it, or set it to false to read this config alone.` }).optional().describe("Whether this config builds on the one a design-system package declares (\"onsystem\" in the package.json of a dependency, found from the dependencies): its rules apply over the package's, and, unless it sets components of its own, what it leaves out comes from the package's (components with their importPath, docs, elements, tokens and libraries). Default: true. false reads this config alone."),
3571
3587
  components: globsSchema.optional().describe("Globs for component source files, relative to the root."),
3572
3588
  exclude: z.array(z.string()).optional().describe("Globs for files to leave out, relative to the root: `check` in folders and globs, the ESLint plugin, the Claude Code hook and check_ui leave them out, and finding components skips them. A folder (`app/legacy`) covers everything in it. Adds to the defaults (node_modules, .d.ts files, tests and stories); a pattern that starts with `!` takes out the default it names, such as `!**/*.d.ts`."),
3573
3589
  tokens: z.union([tokenSourceSchema, z.array(tokenSourceSchema)]).optional().describe("Token files: W3C DTCG JSON (*.json), CSS custom properties (*.css) or a DESIGN.md's front matter (*.md)."),
3574
3590
  docs: globsSchema.optional().describe("Globs for per-component Markdown/MDX docs."),
3575
3591
  tsconfig: z.string().optional().describe("tsconfig used to resolve types and path aliases."),
3576
- importPath: z.string().optional().describe("Package name components are imported from, e.g. \"@acme/ui\", or a pattern for packages imported by path: \"@acme/ui/{path}\" is @acme/ui/primitives/button for primitives/button.tsx in the package. Default: inferred from package exports and tsconfig paths."),
3592
+ importPath: z.string().optional().describe("Package name components are imported from, e.g. \"@acme/ui\", or a pattern for packages imported by path: \"@acme/ui/{path}\" is @acme/ui/primitives/button for primitives/button.tsx in the package. An installed dependency it names exactly, whose component sources the project does not read, is read from its type declarations (.d.ts). Default: inferred from package exports and tsconfig paths."),
3577
3593
  elements: z.record(z.string(), z.string()).optional().describe("Extra native element → component mappings, e.g. { \"a\": \"Link\" }."),
3578
3594
  libraries: librariesSchema.optional().describe(`Published component libraries to read from their type declarations when the project depends on them. Omitted: every one onsystem reads (${LIBRARY_IDS.join(", ")}). false: none. A list: only those, e.g. ["mui"].`),
3579
3595
  includeDesignSystem: z.boolean().optional().describe("check: lint the design system's own component files too. Off by default: they implement the scale and primitives the rules enforce."),
@@ -3653,9 +3669,11 @@ async function loadConfigWith(options, context = {}) {
3653
3669
  let packages = context.packages?.root === searchDir ? context.packages : void 0;
3654
3670
  const finder = (dir) => packages?.root === dir ? packages : packages = new PackageFinder(dir);
3655
3671
  let lookup = context.lookup;
3672
+ /**
3673
+ * The project's own config: a config file in the folder searched, `--config`, or a workspace
3674
+ * root's that a project in it takes on.
3675
+ */
3656
3676
  let configFile;
3657
- /** The design-system package whose config this project takes on, having none of its own. */
3658
- let declared;
3659
3677
  if (options.config) configFile = path.resolve(cwd, options.config);
3660
3678
  else {
3661
3679
  const find = (names) => names.map((name) => path.join(searchDir, name)).find((file) => fs.existsSync(file));
@@ -3663,120 +3681,134 @@ async function loadConfigWith(options, context = {}) {
3663
3681
  const legacy = find(LEGACY_CONFIG_FILES);
3664
3682
  if (legacy) deprecations.push(legacyConfigNotice(legacy, configFile));
3665
3683
  configFile ??= legacy ?? ownDeclaredConfig(searchDir);
3666
- if (!configFile) {
3667
- lookup ??= findDesignSystemPackage(searchDir, finder(searchDir));
3668
- if (lookup?.problems.length) throw declarationError(lookup, searchDir);
3669
- declared = lookup?.found;
3670
- configFile = declared?.config;
3671
- }
3672
- }
3673
- for (const { name, problem } of lookup?.skipped ?? []) warnings.push(`${name}'s onsystem config was skipped: ${problem} ${name} is installed in node_modules, so this project is read as if it declared none.`);
3674
- let raw = {};
3675
- if (configFile) {
3676
- if (!fs.existsSync(configFile)) throw new ConfigError(`Config file not found: ${configFile}`);
3677
- if (options.jsonConfigOnly && !configFile.endsWith(".json")) throw new ConfigError(`${configFile} was not run: the server runs a JavaScript or TypeScript config only in a project it was started in (its working directory, --root or the client's roots), not in one found from the path a tool call names. Start the server in the project, or use onsystem.config.json.`);
3678
- raw = await readConfigFile(configFile);
3679
- }
3680
- const parsed = configSchema.safeParse(raw);
3681
- if (!parsed.success) throw new ConfigError(`Invalid config in ${configFile ?? "(defaults)"}:\n${z.prettifyError(parsed.error)}`);
3682
- const config = parsed.data;
3684
+ }
3685
+ const config = configFile ? await readConfig(configFile, options) : {};
3683
3686
  const root = options.root ? path.resolve(cwd, options.root) : options.config && configFile ? path.dirname(configFile) : searchDir;
3684
3687
  if (!isDirectory(root)) throw new ConfigError(`Project root not found: ${root}`);
3685
- const base = declared ? declared.dir : options.base ? path.resolve(cwd, options.base) : root;
3686
- const inherited = base !== root;
3687
- const fromBase = (p) => inherited ? toPosix(path.relative(root, path.resolve(base, slashGlob(p)))) || "." : p;
3688
+ let declared;
3689
+ if (config.extends !== false) {
3690
+ lookup ??= findDesignSystemPackage(root, finder(root));
3691
+ if (!lookup?.problems.length) declared = lookup?.found;
3692
+ else if (!configFile) throw declarationError(lookup, root);
3693
+ else warnings.push(`${problemLines(lookup.problems)}\nSo ${configFile} is read alone, without the config a design-system package declares. Fix the "${PACKAGE_FIELD}" field in that package, or set "extends": false in ${path.basename(configFile)}.`);
3694
+ }
3695
+ for (const { name, problem } of lookup?.skipped ?? []) {
3696
+ const quoted = json(name);
3697
+ warnings.push(`The onsystem config of ${quoted} was skipped: ${problem} ${quoted} is installed in node_modules, so this project is read as if it declared none.`);
3698
+ }
3699
+ const pkg = declared ? await readConfig(declared.config, options) : void 0;
3700
+ const base = options.base ? path.resolve(cwd, options.base) : root;
3701
+ const rebase = (from) => (p) => from === root ? p : toPosix(path.relative(root, path.resolve(from, slashGlob(p)))) || ".";
3702
+ const fromBase = rebase(base);
3703
+ const fromPackage = rebase(declared?.dir ?? root);
3688
3704
  const toProject = toPosix(path.relative(base, root));
3689
3705
  const excludeFromBase = (e) => {
3690
- if (!inherited || e.startsWith("**/")) return e;
3691
- if (declared) return fromBase(e);
3706
+ if (base === root || e.startsWith("**/")) return e;
3692
3707
  if (e === toProject) return "**";
3693
3708
  return e.startsWith(`${toProject}/`) ? e.slice(toProject.length + 1) : void 0;
3694
3709
  };
3710
+ const excludeFromPackage = (e) => e.startsWith("**/") ? e : fromPackage(e);
3695
3711
  const outside = [];
3696
3712
  const within = (field) => (p) => {
3697
3713
  if (!declared || staysIn(declared.dir, declared.boundary, slashGlob(p))) return true;
3698
- outside.push(`${field} ${p}`);
3714
+ outside.push(`${field} ${json(p)}`);
3699
3715
  return false;
3700
3716
  };
3701
- const configComponents = config.components === void 0 ? void 0 : toArray(config.components).filter(within("components"));
3702
- const configTokens = config.tokens === void 0 ? void 0 : toArray(config.tokens).filter((t) => within("tokens")(typeof t === "string" ? t : t.path));
3703
- const configDocs = config.docs === void 0 ? void 0 : toArray(config.docs).filter(within("docs"));
3704
- if (declared && outside.length) warnings.push(`${declared.name}'s onsystem config names paths outside ${declared.installed ? "the package" : "the workspace"}, which were ignored: ${outside.join(", ")}.`);
3717
+ const pathOf = (t) => typeof t === "string" ? t : t.path;
3718
+ const tokensFrom = (move) => (t) => typeof t === "string" ? { path: move(t) } : {
3719
+ ...t,
3720
+ path: move(t.path)
3721
+ };
3722
+ const packageComponents = pkg?.components === void 0 ? void 0 : toArray(pkg.components).filter(within("components")).map(fromPackage);
3723
+ const packageTokens = pkg?.tokens === void 0 ? void 0 : toArray(pkg.tokens).filter((t) => within("tokens")(pathOf(t))).map(tokensFrom(fromPackage));
3724
+ const packageDocs = pkg?.docs === void 0 ? void 0 : toArray(pkg.docs).filter(within("docs")).map(fromPackage);
3725
+ if (declared && outside.length) warnings.push(`The onsystem config of ${json(declared.name)} names paths outside ${declared.installed ? "the package" : "the workspace"}, which were ignored: ${outside.join(", ")}.`);
3726
+ const inherits = pkg !== void 0 && config.components === void 0;
3727
+ const configComponents = config.components === void 0 ? packageComponents : toArray(config.components).map(fromBase);
3728
+ const configTokens = config.tokens === void 0 ? inherits ? packageTokens : void 0 : toArray(config.tokens).map(tokensFrom(fromBase));
3729
+ const configDocs = config.docs === void 0 ? inherits ? packageDocs : void 0 : toArray(config.docs).map(fromBase);
3730
+ const importPath = config.importPath ?? (inherits ? pkg.importPath : void 0);
3731
+ const librariesSetting = config.libraries ?? (inherits ? pkg.libraries : void 0);
3705
3732
  const rules = {};
3706
3733
  for (const id of RULE_IDS) {
3707
- const setting = config.rules?.[id];
3734
+ const setting = config.rules?.[id] ?? pkg?.rules?.[id];
3708
3735
  rules[id] = setting === void 0 ? {
3709
3736
  severity: DEFAULT_SEVERITY[id],
3710
3737
  options: {}
3711
3738
  } : resolveRule(setting);
3712
3739
  }
3713
- const overrides = (declared ? [] : config.overrides ?? []).map((override) => ({
3740
+ const overrides = (config.overrides ?? []).map((override) => ({
3714
3741
  files: toArray(override.files).map(slashGlob),
3715
3742
  rules: Object.fromEntries(Object.entries(override.rules).map(([id, setting]) => [id, resolveRule(setting)]))
3716
3743
  }));
3717
- const tsconfig = config.tsconfig === void 0 || declared ? void 0 : fromBase(config.tsconfig.replaceAll("\\", "/"));
3744
+ const tsconfig = config.tsconfig === void 0 ? void 0 : fromBase(config.tsconfig.replaceAll("\\", "/"));
3718
3745
  if (tsconfig !== void 0 && !fs.existsSync(path.resolve(root, tsconfig))) throw new ConfigError(`tsconfig not found: ${path.resolve(root, tsconfig)} (from "tsconfig" in ${configFile ?? "the config"})`);
3719
- const componentsSet = Boolean(options.components?.length) || config.components !== void 0;
3746
+ const componentsSet = Boolean(options.components?.length) || configComponents !== void 0;
3720
3747
  const detection = componentsSet ? void 0 : detectProject(root, tsconfig, void 0, declared, finder(root));
3721
3748
  const explicitTokens = options.tokens?.length ? {
3722
3749
  paths: options.tokens,
3723
3750
  from: "--tokens"
3724
- } : config.tokens !== void 0 && !declared ? {
3725
- paths: toArray(config.tokens).map((t) => fromBase(typeof t === "string" ? t : t.path)),
3751
+ } : config.tokens !== void 0 ? {
3752
+ paths: toArray(config.tokens).map((t) => fromBase(pathOf(t))),
3726
3753
  from: `"tokens" in ${configFile ?? "the config"}`
3727
3754
  } : void 0;
3728
3755
  for (const file of explicitTokens?.paths ?? []) {
3729
3756
  const absolute = path.resolve(root, slashGlob(file));
3730
3757
  if (!/[*?[\]{}()!]/.test(file) && !fs.existsSync(absolute)) throw new ConfigError(`Token file not found: ${absolute} (from ${explicitTokens?.from})`);
3731
3758
  }
3732
- const components = (options.components?.length ? options.components : configComponents !== void 0 ? configComponents.map(fromBase) : detection ? [...detection.withDefaults ? DEFAULT_COMPONENTS : [], ...detection.components] : DEFAULT_COMPONENTS).map(slashGlob);
3733
- const libraries = detectLibraries(root, config.libraries, {
3734
- importPath: config.importPath,
3759
+ const components = (options.components?.length ? options.components : configComponents ?? (detection ? [...detection.withDefaults ? DEFAULT_COMPONENTS : [], ...detection.components] : DEFAULT_COMPONENTS)).map(slashGlob);
3760
+ const libraries = detectLibraries(root, librariesSetting, {
3761
+ importPath,
3735
3762
  components
3736
3763
  });
3737
- const tokenSources = options.tokens?.length ? options.tokens.map((p) => ({ path: p })) : configTokens !== void 0 ? configTokens.map((t) => typeof t === "string" ? { path: fromBase(t) } : {
3738
- ...t,
3739
- path: fromBase(t.path)
3740
- }) : [...(detection?.tokens.length ? [...detection.tokens, ...DEFAULT_TOKENS.filter((t) => t.endsWith(".json"))] : DEFAULT_TOKENS).map((p) => ({ path: p })), ...FALLBACK_TOKENS.map((p) => ({
3764
+ const tokenSources = options.tokens?.length ? options.tokens.map((p) => ({ path: p })) : configTokens ?? [...(detection?.tokens.length ? [...detection.tokens, ...DEFAULT_TOKENS.filter((t) => t.endsWith(".json"))] : DEFAULT_TOKENS).map((p) => ({ path: p })), ...FALLBACK_TOKENS.map((p) => ({
3741
3765
  path: p,
3742
3766
  fallback: true
3743
3767
  }))];
3744
3768
  const tidy = (e) => slashGlob(e).replace(/^(?:\.\/)+/, "").replace(/(.)\/+$/, "$1");
3745
- const listed = config.exclude ?? [];
3746
- const dropped = new Set(listed.filter((e) => e.startsWith("!")).map((e) => tidy(e.slice(1))));
3747
- for (const pattern of dropped) {
3748
- if (DEFAULT_EXCLUDE.includes(pattern)) continue;
3749
- warnings.push(`"!${pattern}" in "exclude" takes out none of the defaults, which are ${DEFAULT_EXCLUDE.map((d) => `"${d}"`).join(", ")}: a pattern that starts with "!" names one of them.`);
3750
- }
3751
- const exclude = [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDE.filter((d) => !dropped.has(d)), ...listed.filter((e) => !e.startsWith("!")).map(tidy)])].map(excludeFromBase).filter((e) => e !== void 0);
3769
+ const excludeOf = (listed, move) => {
3770
+ const dropped = new Set(listed.filter((e) => e.startsWith("!")).map((e) => tidy(e.slice(1))));
3771
+ for (const pattern of dropped) {
3772
+ if (DEFAULT_EXCLUDE.includes(pattern)) continue;
3773
+ warnings.push(`"!${pattern}" in "exclude" takes out none of the defaults, which are ${DEFAULT_EXCLUDE.map((d) => `"${d}"`).join(", ")}: a pattern that starts with "!" names one of them.`);
3774
+ }
3775
+ return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDE.filter((d) => !dropped.has(d)), ...listed.filter((e) => !e.startsWith("!")).map(tidy)])].map(move).filter((e) => e !== void 0);
3776
+ };
3777
+ const exclude = excludeOf(config.exclude ?? [], excludeFromBase);
3778
+ const packageExclude = pkg?.exclude ? excludeOf(pkg.exclude, excludeFromPackage) : void 0;
3752
3779
  return {
3753
3780
  root,
3754
- configFile,
3781
+ configFile: configFile ?? declared?.config,
3755
3782
  components,
3756
- exclude: declared && config.exclude ? [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDE, ...exclude])] : exclude,
3757
- ...declared && config.exclude ? { designSystemExclude: exclude } : {},
3783
+ exclude: packageExclude ? [.../* @__PURE__ */ new Set([...exclude, ...packageExclude.filter((e) => !DEFAULT_EXCLUDE.includes(e))])] : exclude,
3784
+ ...packageExclude && inherits ? { designSystemExclude: packageExclude } : {},
3758
3785
  tokens: tokenSources.map((t) => ({
3759
3786
  ...t,
3760
3787
  path: slashGlob(t.path)
3761
3788
  })),
3762
3789
  ...!options.tokens?.length && configTokens === void 0 ? { tokensDetected: true } : {},
3763
- docs: (options.docs?.length ? options.docs : configDocs === void 0 ? [.../* @__PURE__ */ new Set([...DEFAULT_DOCS, ...detection?.docs ?? []])] : configDocs.map(fromBase)).map(slashGlob),
3790
+ docs: (options.docs?.length ? options.docs : configDocs ?? [.../* @__PURE__ */ new Set([...DEFAULT_DOCS, ...detection?.docs ?? []])]).map(slashGlob),
3764
3791
  tsconfig,
3765
- importPath: config.importPath,
3766
- imports: detection?.imports ?? (declared && componentsSet ? packageImports(root, declared.dir) : []),
3792
+ importPath,
3793
+ imports: detection?.imports ?? (declared && inherits && componentsSet ? packageImports(root, declared.dir) : []),
3767
3794
  detected: detection?.source,
3768
3795
  ...detection?.designSystems ? { designSystems: detection.designSystems } : {},
3769
3796
  tailwindConfig: findTailwindConfig(root),
3770
3797
  libraries: libraries.read,
3771
- libraryIds: config.libraries,
3798
+ libraryIds: librariesSetting,
3772
3799
  librariesOff: libraries.off,
3773
3800
  librariesUnread: libraries.unread,
3774
- elements: config.elements ?? {},
3775
- includeDesignSystem: options.includeDesignSystem ?? (declared ? void 0 : config.includeDesignSystem) ?? false,
3801
+ elements: {
3802
+ ...inherits ? pkg.elements : {},
3803
+ ...config.elements
3804
+ },
3805
+ includeDesignSystem: options.includeDesignSystem ?? config.includeDesignSystem ?? false,
3776
3806
  rules,
3777
3807
  overrides,
3778
- ...inherited && !declared ? { base } : {},
3808
+ ...base !== root ? { base } : {},
3779
3809
  ...declared ? { designSystemPackage: declared } : {},
3810
+ ...inherits ? { inherits: true } : {},
3811
+ ...config.extends === false ? { extendsOff: true } : {},
3780
3812
  configured: componentsSet,
3781
3813
  ...deprecations.length ? { deprecations } : {},
3782
3814
  ...warnings.length ? { warnings } : {}
@@ -3785,7 +3817,7 @@ async function loadConfigWith(options, context = {}) {
3785
3817
  /**
3786
3818
  * Whether `dir` has a config of its own: a config file under its current or old name, or one its
3787
3819
  * package.json names in its `onsystem` field. Without one, a project takes on its design-system
3788
- * package's config, else the workspace root's.
3820
+ * package's config, and, from a workspace root, the root's over it.
3789
3821
  */
3790
3822
  function hasOwnConfig(dir) {
3791
3823
  if ([...CONFIG_FILES, ...LEGACY_CONFIG_FILES].some((name) => fs.existsSync(path.join(dir, name)))) return true;
@@ -3797,9 +3829,17 @@ function ownDeclaredConfig(dir) {
3797
3829
  if (declared && "problem" in declared) throw new ConfigError(declared.problem);
3798
3830
  return declared?.file;
3799
3831
  }
3832
+ /**
3833
+ * Each declaration that cannot be read, on a line of its own. The package's name is the
3834
+ * repository's text (any string can name a dependency in package.json), so it is JSON-quoted:
3835
+ * a line break or a sentence in it stays inside the quotes, on that line.
3836
+ */
3837
+ function problemLines(problems) {
3838
+ return problems.map((p) => `${json(p.name)}: ${p.problem}`).join("\n");
3839
+ }
3800
3840
  /** The error for workspace packages whose declaration cannot be read: their team can fix them. */
3801
3841
  function declarationError(lookup, dir) {
3802
- return new ConfigError(`${lookup.problems.map((p) => `${p.name}: ${p.problem}`).join("\n")}\nFix the "${PACKAGE_FIELD}" field in that package, or give ${dir} a config of its own (onsystem.config.json), which is read instead.`);
3842
+ return new ConfigError(`${problemLines(lookup.problems)}\nFix the "${PACKAGE_FIELD}" field in that package, or give ${dir} a config of its own (onsystem.config.json), which is read instead.`);
3803
3843
  }
3804
3844
  /**
3805
3845
  * Whether a path or glob in a dependency's config, relative to its folder `dir`, stays in
@@ -3863,6 +3903,14 @@ function nodeRunsTypeScript(version = process.versions.node) {
3863
3903
  const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
3864
3904
  return major > 23 || major === 23 && minor >= 6 || major === 22 && minor >= 18;
3865
3905
  }
3906
+ /** A config file, read and validated: a JSON one only with `jsonConfigOnly`. */
3907
+ async function readConfig(file, options) {
3908
+ if (!fs.existsSync(file)) throw new ConfigError(`Config file not found: ${file}`);
3909
+ if (options.jsonConfigOnly && !file.endsWith(".json")) throw new ConfigError(`${file} was not run: the server runs a JavaScript or TypeScript config only in a project it was started in (its working directory, --root or the client's roots), not in one found from the path a tool call names. Start the server in the project, or use onsystem.config.json.`);
3910
+ const parsed = configSchema.safeParse(await readConfigFile(file));
3911
+ if (!parsed.success) throw new ConfigError(`Invalid config in ${file}:\n${z.prettifyError(parsed.error)}`);
3912
+ return parsed.data;
3913
+ }
3866
3914
  async function readConfigFile(file) {
3867
3915
  if (/\.[cm]?ts$/.test(file) && !nodeRunsTypeScript()) throw new ConfigError(`${file} is TypeScript, which Node.js runs from 22.18; this is ${process.version}. Use onsystem.config.json or .mjs, or Node.js 22.18 or later.`);
3868
3916
  if (file.endsWith(".json")) try {
@@ -4913,6 +4961,8 @@ function dedent(code) {
4913
4961
  }
4914
4962
  //#endregion
4915
4963
  //#region src/libraries/declarations.ts
4964
+ /** A component's name: a capitalised identifier (`Button`, `ButtonBase`, `Unstable_Grid2`). */
4965
+ const COMPONENT_NAME = /^[A-Z][\w$]*$/;
4916
4966
  /** Packages contributing more props than this are summarised, as in source extraction. */
4917
4967
  const MAX_LISTED_INHERITED = 40;
4918
4968
  /**
@@ -4984,9 +5034,9 @@ function extractLibrary(options) {
4984
5034
  const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
4985
5035
  const isDefault = exported.getName() === "default";
4986
5036
  const name = isDefault && subpath && declaration ? ts.getNameOfDeclaration(declaration)?.getText() ?? "default" : exported.getName();
4987
- if (isDefault && !/^[A-Z]/.test(name)) continue;
5037
+ if (isDefault && !COMPONENT_NAME.test(name)) continue;
4988
5038
  exportNames.add(name);
4989
- if (!/^[A-Z]/.test(name)) continue;
5039
+ if (!COMPONENT_NAME.test(name)) continue;
4990
5040
  const known = bySymbol.get(symbol);
4991
5041
  if (known) {
4992
5042
  if (known.name !== name && !known.aliases.includes(name)) known.aliases.push(name);
@@ -8179,7 +8229,8 @@ function table(tokens, keys) {
8179
8229
  use(token),
8180
8230
  ...described ? [token.description ?? ""] : []
8181
8231
  ]);
8182
- const line = (cells) => `| ${cells.map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`;
8232
+ const oneLine = (text) => text.split(/\r\n?|\n/).map((part) => part.trim()).filter(Boolean).join(" ");
8233
+ const line = (cells) => `| ${cells.map((c) => oneLine(c).replace(/\|/g, "\\|")).join(" | ")} |`;
8183
8234
  return [
8184
8235
  line(header),
8185
8236
  line(header.map(() => "---")),
@@ -10411,7 +10462,7 @@ function maskReferencedGradientIds(elements) {
10411
10462
  for (const element of elements) if (element.tag === "mask") refsFrom(element.node);
10412
10463
  return ids;
10413
10464
  }
10414
- const noHardcodedColor = {
10465
+ const noHardcodedColor = shortQuotes({
10415
10466
  id: "no-hardcoded-color",
10416
10467
  description: "Colors must come from design tokens: no hex/rgb/oklch literals, arbitrary Tailwind colors or default-palette classes.",
10417
10468
  run(context) {
@@ -10622,7 +10673,31 @@ const noHardcodedColor = {
10622
10673
  }
10623
10674
  reportChartTokens(context, allow);
10624
10675
  }
10625
- };
10676
+ });
10677
+ /** The longest code a message quotes. */
10678
+ const QUOTED_CODE = 120;
10679
+ /**
10680
+ * The rule, with each `code` span in its messages cut to QUOTED_CODE characters. A message quotes
10681
+ * the class, its fix and the value whole, so one class with 33,000 `hover:` variants made a
10682
+ * message of 400,000 characters for the agent to read.
10683
+ */
10684
+ function shortQuotes(rule) {
10685
+ const cut = (span, code) => code.length > QUOTED_CODE ? `\`${truncate(code, QUOTED_CODE)}\`` : span;
10686
+ return {
10687
+ ...rule,
10688
+ run(context) {
10689
+ rule.run({
10690
+ ...context,
10691
+ report(report) {
10692
+ context.report({
10693
+ ...report,
10694
+ message: report.message.replace(/`([^`]+)`/g, cut)
10695
+ });
10696
+ }
10697
+ });
10698
+ }
10699
+ };
10700
+ }
10626
10701
  /** Packages that draw charts: Recharts, Chart.js, Nivo, visx, Victory, Tremor, MUI X Charts, D3. */
10627
10702
  const CHART_LIBRARY = /chart|^@nivo\/|^@visx\/|^victory|^@tremor\/|^d3(?:-|$)/i;
10628
10703
  /** Roles of an element that is a drawing: a chart drawn by hand is an image. */
@@ -11211,25 +11286,69 @@ function setsColor(utility, prefix) {
11211
11286
  */
11212
11287
  function keepStatesApart(list) {
11213
11288
  const after = (listed) => listed.report?.fix?.[0]?.text ?? listed.value;
11289
+ const order = new Map(list.map((listed, i) => [listed, i]));
11290
+ const parsed = /* @__PURE__ */ new Map();
11291
+ const parse = (text) => {
11292
+ let utility = parsed.get(text);
11293
+ if (!utility) {
11294
+ utility = parseUtility(text);
11295
+ parsed.set(text, utility);
11296
+ }
11297
+ return utility;
11298
+ };
11299
+ const byText = /* @__PURE__ */ new Map();
11300
+ const byUtility = /* @__PURE__ */ new Map();
11301
+ const utilityKey = (text) => `${parse(text).base}\0${parse(text).modifier}`;
11302
+ const place = (listed) => {
11303
+ const text = after(listed);
11304
+ const same = byText.get(text) ?? [];
11305
+ byText.set(text, same);
11306
+ const index = order.get(listed) ?? 0;
11307
+ same.splice(same.findLastIndex((other) => (order.get(other) ?? 0) < index) + 1, 0, listed);
11308
+ const texts = byUtility.get(utilityKey(text)) ?? /* @__PURE__ */ new Set();
11309
+ byUtility.set(utilityKey(text), texts.add(text));
11310
+ };
11311
+ const unplace = (listed) => {
11312
+ const text = after(listed);
11313
+ const same = byText.get(text) ?? [];
11314
+ same.splice(same.indexOf(listed), 1);
11315
+ if (!same.length) byUtility.get(utilityKey(text))?.delete(text);
11316
+ };
11317
+ for (const listed of list) place(listed);
11214
11318
  for (const pass of ["state", "base"]) for (const listed of list) {
11215
11319
  const { report } = listed;
11216
11320
  if (!report?.fix?.[0]) continue;
11217
- for (const other of list) {
11218
- if (other === listed || oneColor(listed.color, other.color)) continue;
11219
- const [stateListed, baseListed] = pass === "state" ? [listed, other] : [other, listed];
11220
- const state = parseUtility(after(stateListed));
11221
- const base = parseUtility(after(baseListed));
11321
+ const text = after(listed);
11322
+ let found;
11323
+ for (const otherText of byUtility.get(utilityKey(text)) ?? []) {
11324
+ if (otherText === text) continue;
11325
+ const [state, base] = pass === "state" ? [parse(text), parse(otherText)] : [parse(otherText), parse(text)];
11222
11326
  const conditions = conditionsBetween(state, base);
11223
- if (!conditions || hasDarkBase(list, stateListed, baseListed, state, base, after)) continue;
11224
- const had = pass === "base" ? `\`${other.value}\` is in the class list too` : after(other) === other.value ? `The class list already has \`${other.value}\`` : `\`${other.value}\` becomes \`${after(other)}\` too`;
11225
- const apart = state.modifier ? "such as with another opacity modifier" : `such as with \`${withBase({
11226
- ...state,
11227
- modifier: "/90"
11228
- }, state.base)}\``;
11229
- report.message += ` ${had}, so with this fix \`${conditions}\` would change nothing: it is not applied automatically. Keep the two apart, ${apart}.`;
11230
- report.fix = void 0;
11231
- break;
11327
+ if (!conditions) continue;
11328
+ for (const other of byText.get(otherText) ?? []) {
11329
+ if (found && (order.get(other) ?? 0) > (order.get(found.other) ?? 0)) break;
11330
+ if (oneColor(listed.color, other.color)) continue;
11331
+ const [stateListed, baseListed] = pass === "state" ? [listed, other] : [other, listed];
11332
+ if (hasDarkBase(list, stateListed, baseListed, state, base, after)) continue;
11333
+ found = {
11334
+ other,
11335
+ state,
11336
+ conditions
11337
+ };
11338
+ break;
11339
+ }
11232
11340
  }
11341
+ if (!found) continue;
11342
+ const { other, state, conditions } = found;
11343
+ const had = pass === "base" ? `\`${other.value}\` is in the class list too` : after(other) === other.value ? `The class list already has \`${other.value}\`` : `\`${other.value}\` becomes \`${after(other)}\` too`;
11344
+ const apart = state.modifier ? "such as with another opacity modifier" : `such as with \`${withBase({
11345
+ ...state,
11346
+ modifier: "/90"
11347
+ }, state.base)}\``;
11348
+ report.message += ` ${had}, so with this fix \`${conditions}\` would change nothing: it is not applied automatically. Keep the two apart, ${apart}.`;
11349
+ unplace(listed);
11350
+ report.fix = void 0;
11351
+ place(listed);
11233
11352
  }
11234
11353
  }
11235
11354
  /**
@@ -11409,7 +11528,8 @@ function scopes(context, element, classes) {
11409
11528
  if (name && !/^Sidebar(?:Provider|Inset)$/.test(name)) tags.push(name);
11410
11529
  }
11411
11530
  const { tokens, components } = context.target;
11412
- const found = scopesIn(tokens.scopedFamilies, context.file, classes, ...tags);
11531
+ const found = scopesIn(tokens.scopedFamilies, context.file, ...tags);
11532
+ if (classes) for (const family of classScopes(context, classes)) found.add(family);
11413
11533
  const files = /* @__PURE__ */ new Set([context.file]);
11414
11534
  for (const tag of tags) {
11415
11535
  const file = components.get(tag)?.source.file;
@@ -11421,6 +11541,25 @@ function scopes(context, element, classes) {
11421
11541
  }
11422
11542
  return found;
11423
11543
  }
11544
+ /** The scoped families each class string of a check names, by its text. */
11545
+ const classScopeCache = /* @__PURE__ */ new WeakMap();
11546
+ /**
11547
+ * The scoped families a class string names, read once per string and check: each color in it
11548
+ * asks, and reading the whole string again for each made a list of thousands quadratic.
11549
+ */
11550
+ function classScopes(context, classes) {
11551
+ let cache = classScopeCache.get(context.analysis);
11552
+ if (!cache) {
11553
+ cache = /* @__PURE__ */ new Map();
11554
+ classScopeCache.set(context.analysis, cache);
11555
+ }
11556
+ let found = cache.get(classes);
11557
+ if (!found) {
11558
+ found = scopesIn(context.target.tokens.scopedFamilies, classes);
11559
+ cache.set(classes, found);
11560
+ }
11561
+ return found;
11562
+ }
11424
11563
  /** The variant prop of the element's component that already sets `prefix-*` classes, if any. */
11425
11564
  function variantProp(context, element, prefix) {
11426
11565
  const resolution = context.resolve(element);
@@ -11443,7 +11582,7 @@ function numberValue(init) {
11443
11582
  if (ts.isPrefixUnaryExpression(init) && ts.isNumericLiteral(init.operand) && (init.operator === ts.SyntaxKind.MinusToken || init.operator === ts.SyntaxKind.PlusToken)) return `${init.operator === ts.SyntaxKind.MinusToken ? "-" : ""}${init.operand.text}`;
11444
11583
  }
11445
11584
  function lengthRule(id, description, spec) {
11446
- return {
11585
+ return shortQuotes({
11447
11586
  id,
11448
11587
  description,
11449
11588
  run(context) {
@@ -11515,7 +11654,7 @@ function lengthRule(id, description, spec) {
11515
11654
  }
11516
11655
  }
11517
11656
  }
11518
- };
11657
+ });
11519
11658
  }
11520
11659
  /**
11521
11660
  * A value on the scale whose token code cannot name, with neither a Tailwind class nor a
@@ -11998,7 +12137,7 @@ function isObject(value) {
11998
12137
  var name = "onsystem";
11999
12138
  //#endregion
12000
12139
  //#region src/version.ts
12001
- const VERSION = "0.9.1";
12140
+ const VERSION = "0.10.1";
12002
12141
  const NAME = name;
12003
12142
  //#endregion
12004
12143
  //#region src/lint/sarif.ts
@@ -12221,7 +12360,7 @@ function checkSource(code, target, options) {
12221
12360
  try {
12222
12361
  return checkParsed(code, target, options, file);
12223
12362
  } catch (error) {
12224
- if (!isStackOverflow(error)) throw error;
12363
+ if (!isStackOverflow$1(error)) throw error;
12225
12364
  return {
12226
12365
  file,
12227
12366
  diagnostics: [{
@@ -12239,7 +12378,7 @@ function checkSource(code, target, options) {
12239
12378
  };
12240
12379
  }
12241
12380
  }
12242
- function isStackOverflow(error) {
12381
+ function isStackOverflow$1(error) {
12243
12382
  return error instanceof RangeError && /call stack/i.test(error.message);
12244
12383
  }
12245
12384
  function checkParsed(code, target, options, file) {
@@ -12363,7 +12502,7 @@ function uncheckedNotice(results) {
12363
12502
  const list = [...names];
12364
12503
  const shown = list.slice(0, 4).map((n) => `<${n}>`);
12365
12504
  const more = list.length > shown.length ? ` and ${list.length - shown.length} more` : "";
12366
- const from = [...modules].slice(0, 3).map((m) => `"${m}"`);
12505
+ const from = [...modules].slice(0, 3).map((m) => JSON.stringify(m));
12367
12506
  const moreModules = modules.size > from.length ? ` and ${modules.size - from.length} more` : "";
12368
12507
  const where = results.length > 1 ? ` (in ${files} ${files === 1 ? "file" : "files"})` : "";
12369
12508
  return `${shown.join(", ")}${more} ${list.length === 1 ? "comes" : "come"} from ${from.join(", ")}${moreModules}, which the design-system model does not include, so ${list.length === 1 ? "it was" : "they were"} not checked${where}. Extraction looks incomplete: \`inspect\` lists what was found; add the missing files to "components" in the config.`;
@@ -13080,6 +13219,38 @@ function repositoryOf(dir) {
13080
13219
  if (current === path.dirname(current)) return void 0;
13081
13220
  }
13082
13221
  }
13222
+ /**
13223
+ * `extractComponents`, without the component files nested too deeply for TypeScript's recursive
13224
+ * parser (thousands of levels), each named in a warning: one such file would otherwise fail the
13225
+ * whole design system with a RangeError that names no file.
13226
+ */
13227
+ function extractReadable(options) {
13228
+ try {
13229
+ return extractComponents(options);
13230
+ } catch (error) {
13231
+ if (!isStackOverflow(error)) throw error;
13232
+ const tooDeep = new Set(options.files.filter(overflowsParser));
13233
+ if (!tooDeep.size) throw error;
13234
+ const extracted = extractComponents({
13235
+ ...options,
13236
+ files: options.files.filter((file) => !tooDeep.has(file))
13237
+ });
13238
+ for (const file of tooDeep) extracted.warnings.push(`${relativePath(options.root, file)}: nested too deeply to read (thousands of levels); skipped.`);
13239
+ return extracted;
13240
+ }
13241
+ }
13242
+ /** Whether parsing `file` on its own runs out of stack. */
13243
+ function overflowsParser(file) {
13244
+ try {
13245
+ ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest);
13246
+ return false;
13247
+ } catch (error) {
13248
+ return isStackOverflow(error);
13249
+ }
13250
+ }
13251
+ function isStackOverflow(error) {
13252
+ return error instanceof RangeError && /call stack/i.test(error.message);
13253
+ }
13083
13254
  /** What `inspect` and the server's log say about files that lead outside the project. */
13084
13255
  function outsideWarning(root, files) {
13085
13256
  const shown = files.map((file) => relativePath(root, file)).sort();
@@ -13227,11 +13398,54 @@ async function cacheKey(config, files, dependencies, stats = new FileStats(confi
13227
13398
  stats: await stats.of(all)
13228
13399
  })).digest("hex");
13229
13400
  }
13401
+ /**
13402
+ * Where the model is cached: the project's `node_modules/.cache`, else (`temp`) a folder of the
13403
+ * user's own in the system temp folder, `onsystem-<uid>`.
13404
+ */
13230
13405
  function cacheFile(root) {
13231
13406
  const nodeModules = path.join(root, "node_modules");
13232
- if (fs.existsSync(nodeModules)) return path.join(nodeModules, ".cache", "onsystem", "model.json");
13407
+ if (fs.existsSync(nodeModules)) return {
13408
+ file: path.join(nodeModules, ".cache", "onsystem", "model.json"),
13409
+ temp: false
13410
+ };
13233
13411
  const id = createHash("sha256").update(root).digest("hex").slice(0, 16);
13234
- return path.join(os.tmpdir(), "onsystem", `${id}.json`);
13412
+ const user = process.getuid ? `-${process.getuid()}` : "";
13413
+ return {
13414
+ file: path.join(os.tmpdir(), `onsystem${user}`, `${id}.json`),
13415
+ temp: true
13416
+ };
13417
+ }
13418
+ /**
13419
+ * Why the cache folder in the system temp folder cannot be trusted, or undefined when it can: it
13420
+ * must be a folder, not a link, that the user owns and no one else can write to. On a multi-user
13421
+ * host the temp folder is shared, and a model planted in it would reach the agent. With `create`,
13422
+ * it is created (or closed) for the user first. Windows gives each user a temp folder of their own.
13423
+ */
13424
+ async function untrustedCacheFolder(folder, create) {
13425
+ if (!process.getuid) return void 0;
13426
+ if (create) await fsp.mkdir(folder, {
13427
+ recursive: true,
13428
+ mode: 448
13429
+ });
13430
+ const stat = await fsp.lstat(folder);
13431
+ if (!stat.isDirectory()) return `${folder} is not a folder`;
13432
+ if (stat.uid !== process.getuid()) return `${folder} belongs to another user`;
13433
+ if (!create && stat.mode & 18) return `other users can write to ${folder}`;
13434
+ if (create && stat.mode & 63) await fsp.chmod(folder, 448);
13435
+ }
13436
+ /**
13437
+ * Writes `text` beside `file`, then renames it into place: a run reading it never sees half. With
13438
+ * `own`, only the user can read it (the temp folder's cache); without, it has the default mode, so
13439
+ * a cache in `node_modules` built by one user (a Docker build as root) is still read by another.
13440
+ */
13441
+ async function writeWhole(file, text, own) {
13442
+ const partial = `${file}.${process.pid}-${randomUUID()}.tmp`;
13443
+ try {
13444
+ await fsp.writeFile(partial, text, own ? { mode: 384 } : {});
13445
+ await fsp.rename(partial, file);
13446
+ } finally {
13447
+ await fsp.rm(partial, { force: true });
13448
+ }
13235
13449
  }
13236
13450
  /**
13237
13451
  * Fingerprint of the files a config tracks (paths, sizes, mtimes), plus the
@@ -13245,10 +13459,10 @@ async function buildModel(config, options = {}) {
13245
13459
  const started = performance.now();
13246
13460
  const files = await resolveFiles(config);
13247
13461
  const useCache = options.cache !== false;
13248
- const cachePath = cacheFile(config.root);
13462
+ const { file: cachePath, temp } = cacheFile(config.root);
13249
13463
  let cached;
13250
13464
  if (useCache && !options.oldProgram) try {
13251
- cached = JSON.parse(await fsp.readFile(cachePath, "utf8"));
13465
+ if (!(temp && await untrustedCacheFolder(path.dirname(cachePath), false))) cached = JSON.parse(await fsp.readFile(cachePath, "utf8"));
13252
13466
  } catch {}
13253
13467
  const known = Array.isArray(cached?.dependencies) ? cached.dependencies : options.oldProgram ? projectFiles(options.oldProgram) : [];
13254
13468
  const stats = new FileStats(config.root);
@@ -13262,7 +13476,7 @@ async function buildModel(config, options = {}) {
13262
13476
  dependencies: cached.dependencies
13263
13477
  };
13264
13478
  }
13265
- const extracted = extractComponents({
13479
+ const extracted = extractReadable({
13266
13480
  root: config.root,
13267
13481
  files: files.components,
13268
13482
  tsconfig: config.tsconfig,
@@ -13324,12 +13538,16 @@ async function buildModel(config, options = {}) {
13324
13538
  ]);
13325
13539
  const key = await cacheKey(config, files, dependencies, stats);
13326
13540
  if (useCache) try {
13327
- await fsp.mkdir(path.dirname(cachePath), { recursive: true });
13328
- await fsp.writeFile(cachePath, JSON.stringify({
13541
+ const folder = path.dirname(cachePath);
13542
+ let untrusted;
13543
+ if (temp) untrusted = await untrustedCacheFolder(folder, true);
13544
+ else await fsp.mkdir(folder, { recursive: true });
13545
+ if (untrusted) logger.warn(`not caching the model: ${untrusted}. Install the project's dependencies, so the cache goes in node_modules, or set TMPDIR to a folder of your own.`);
13546
+ else await writeWhole(cachePath, JSON.stringify({
13329
13547
  key,
13330
13548
  dependencies,
13331
13549
  model
13332
- }));
13550
+ }), temp);
13333
13551
  } catch (error) {
13334
13552
  logger.warn(`could not write cache ${cachePath}: ${error.message}`);
13335
13553
  }
@@ -13406,15 +13624,16 @@ var DesignSystemHost = class {
13406
13624
  this.watchConfigFiles();
13407
13625
  }
13408
13626
  /**
13409
- * Watches the config file, the tsconfig and the tsconfigs it extends through
13410
- * their directories: editors save by renaming a new file over the old one,
13411
- * which ends a watch on the file. Called again after each build, so a new
13412
- * `extends` target is picked up.
13627
+ * Watches the config file, the config a design-system package declares, which a project with
13628
+ * a config of its own reads under that one, the tsconfig and the tsconfigs it extends through
13629
+ * their directories: editors save by renaming a new file over the old one, which ends a watch
13630
+ * on the file. Called again after each build, so a new `extends` target is picked up.
13413
13631
  */
13414
13632
  watchConfigFiles() {
13415
13633
  const { root, configFile, tsconfig, designSystemPackage } = this.config;
13416
13634
  const files = [
13417
13635
  configFile,
13636
+ designSystemPackage?.config,
13418
13637
  designSystemPackage && path.join(designSystemPackage.dir, "package.json"),
13419
13638
  ...DETECTION_FILES.map((f) => path.join(root, f)),
13420
13639
  ...tsconfig ? [path.resolve(root, tsconfig)] : PROJECT_CONFIG_FILES.map((f) => path.join(root, f)),
@@ -13472,7 +13691,7 @@ var DesignSystemHost = class {
13472
13691
  const { root, configFile, designSystemPackage, warnings } = this.config;
13473
13692
  const load = this.options.loadConfig ?? (() => loadConfig({
13474
13693
  root,
13475
- config: designSystemPackage ? void 0 : configFile
13694
+ config: configFile === designSystemPackage?.config ? void 0 : configFile
13476
13695
  }));
13477
13696
  try {
13478
13697
  this.config = await load();
@@ -13511,4 +13730,4 @@ var DesignSystemHost = class {
13511
13730
  }
13512
13731
  };
13513
13732
  //#endregion
13514
- export { LEGACY_CONFIG_FILES as $, unrecordedRules as A, relativePath as At, statusKinds as B, countBaseline as C, closest as Ct, pruneBaseline as D, SCRIPT_FILE$2 as Dt, onLines as E, unique as Et, loadTokens as F, parseDoc as G, parseDesignMd as H, mergeTokens as I, ConfigError as J, extractComponents as K, toDesignMd as L, writeBaseline as M, defaultProjectConfig as Mt, RULES as N, readProjectConfig as Nt, readBaseline as O, isInside as Ot, TokenIndex as P, DEFAULT_TEST_FOLDERS as Q, toDtcg as R, applyBaseline as S, workspacePatterns as St, goneFiles as T, truncate as Tt, parseCssTokens as U, parseDtcgTokens as V, attachDocs as W, DEFAULT_SEVERITY as X, DEFAULT_DOCS as Y, DEFAULT_TEST_EXCLUDE as Z, formatDiagnostics as _, detectProject as _t, fingerprint as a, loadConfig as at, BASELINE_FILE as b, isWorkspaceRoot as bt, silentLogger as c, formatPx as ct, descriptionOf as d, LIBRARY_IDS as dt, RULE_IDS as et, searchComponents as f, libraryTitle as ft, escapeData as g, declaredConfig as gt, uncheckedNotice as h, PackageFinder as ht, componentFiles as i, hasOwnConfig as it, updateBaseline as j, toPosix as jt, rulesRun as k, matchesGlob as kt, stderrLogger as l, findVariantDefinitions as lt, checkSource as m, PACKAGE_FIELD as mt, DesignSystemHost as n, defineConfig as nt, loadDesignSystem as o, loadConfigWith as ot, applyFixes as p, notReadYet as pt, CONFIG_FILES as q, buildModel as r, excludedBy as rt, tokenFiles as s, rulesFor as st, DesignSystem as t, configJsonSchema as tt, buildSearchIndex as u, LIBRARIES as ut, NAME as v, findDesignSystemPackage as vt, findMoves as w, plural$1 as wt, LEGACY_BASELINE_FILE as x, workspacePackageDirs as xt, VERSION as y, isDesignSystemName as yt, TOKEN_CATEGORIES as z };
13733
+ export { DEFAULT_TOKENS as $, unrecordedRules as A, toPosix as At, statusKinds as B, countBaseline as C, workspacePackageDirs as Ct, pruneBaseline as D, matchesGlob as Dt, onLines as E, isInside as Et, loadTokens as F, unique as Ft, parseDoc as G, parseDesignMd as H, mergeTokens as I, defaultProjectConfig as It, ConfigError as J, extractComponents as K, toDesignMd as L, readProjectConfig as Lt, writeBaseline as M, json as Mt, RULES as N, plural$1 as Nt, readBaseline as O, pathOf as Ot, TokenIndex as P, truncate as Pt, DEFAULT_TEST_FOLDERS as Q, toDtcg as R, applyBaseline as S, isWorkspaceRoot as St, goneFiles as T, SCRIPT_FILE$2 as Tt, parseCssTokens as U, parseDtcgTokens as V, attachDocs as W, DEFAULT_SEVERITY as X, DEFAULT_DOCS as Y, DEFAULT_TEST_EXCLUDE as Z, formatDiagnostics as _, PackageFinder as _t, fingerprint as a, excludedBy as at, BASELINE_FILE as b, findDesignSystemPackage as bt, silentLogger as c, loadConfigWith as ct, descriptionOf as d, findVariantDefinitions as dt, FALLBACK_TOKENS as et, searchComponents as f, LIBRARIES as ft, escapeData as g, PACKAGE_FIELD as gt, uncheckedNotice as h, notReadYet as ht, componentFiles as i, defineConfig as it, updateBaseline as j, closest as jt, rulesRun as k, relativePath as kt, stderrLogger as l, rulesFor as lt, checkSource as m, libraryTitle as mt, DesignSystemHost as n, RULE_IDS as nt, loadDesignSystem as o, hasOwnConfig as ot, applyFixes as p, LIBRARY_IDS as pt, CONFIG_FILES as q, buildModel as r, configJsonSchema as rt, tokenFiles as s, loadConfig as st, DesignSystem as t, LEGACY_CONFIG_FILES as tt, buildSearchIndex as u, formatPx as ut, NAME as v, declaredConfig as vt, findMoves as w, workspacePatterns as wt, LEGACY_BASELINE_FILE as x, isDesignSystemName as xt, VERSION as y, detectProject as yt, TOKEN_CATEGORIES as z };