@multiplatform.one/config 7.34.3 → 7.35.0

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/README.md CHANGED
@@ -127,3 +127,51 @@ runConventionChecks({
127
127
  roots: { root: process.cwd() },
128
128
  });
129
129
  ```
130
+
131
+ ### MPO design guard
132
+
133
+ `pnpm exec mpo-design-lint` checks application source in any repo. With no
134
+ paths it scans whichever of `apps`, `features` and `packages` exist; pass
135
+ directories or source files to change that. It rejects raw controls, `styled()`
136
+ wrappers around MPO components, numeric geometry, inline spacing styles, hex
137
+ colours and local token files (`tokens.ts`, `design-tokens.ts`, or any call to
138
+ `createTokens`, `createTamagui`, `createTheme(s)` or `createFont`). Tokens and
139
+ shared recipes stay in MPO. Numeric geometry on SVG primitives (`Rect`,
140
+ `Path`, `G` and the rest from `react-native-svg` or `@tamagui/react-native-svg`,
141
+ or lowercase DOM SVG elements) is artwork, not layout, and is not reported when
142
+ the enclosing `Svg` has a `viewBox`, including one passed through a spread, or
143
+ when the mark is a fragment drawn into an `Svg` elsewhere. Primitives inside an
144
+ `Svg` with no `viewBox`, and the `Svg` element's own size, are still reported.
145
+
146
+ Every violation is tagged with its rule: `hex`, `geometry`, `inline-spacing`,
147
+ `raw-control`, `styled-mpo` or `token-file`.
148
+
149
+ #### Ratchet
150
+
151
+ ```sh
152
+ pnpm exec mpo-design-lint --baseline design-baseline.json --init # once, commit the file
153
+ pnpm exec mpo-design-lint --baseline design-baseline.json # lint step
154
+ pnpm exec mpo-design-lint --baseline design-baseline.json --frozen # CI
155
+ ```
156
+
157
+ The baseline records the scanned paths (relative to the baseline file) and a
158
+ count per rule. A run fails when any rule's count rises above the baseline, and
159
+ a drop in one rule never pays for a rise in another. When counts only fall, the
160
+ run rewrites the baseline down and exits 0; commit it. `--frozen` fails that
161
+ case instead, so CI catches a lowered count nobody committed. `--list` prints
162
+ the violations on a passing run. `--baseline` may repeat; MPO's `pnpm
163
+ lint:design` ratchets `apps/one` and `apps/storybook-expo` this way.
164
+
165
+ The implementation is also exported from `@multiplatform.one/config/design-lint`
166
+ as `inspectDesign(source, filename)`, `designFiles(path)` and `runDesignLint(paths)`.
167
+ It derives from Pelt's `db7ce75c` guard. TypeScript ships as a runtime dependency.
168
+ Tests, stories, generated files, vendor/build trees and `public/` primitives are
169
+ excluded from discovery. A missing path or empty scan fails. Existing
170
+ `hex-escape: <reason>` file declarations only exempt hex literals, for surfaces
171
+ without a theme runtime. They do not exempt geometry or controls.
172
+
173
+ The guard resolves file-local aliases and spreads; it does not resolve imported
174
+ style objects or prove the absence of arbitrary runtime styling. Review remains
175
+ required by LC-109. MPO ratchets `apps/one` and `apps/storybook-expo` against
176
+ committed baselines; consumer repos should include their feature and integration
177
+ source paths too.
@@ -0,0 +1,524 @@
1
+ #!/usr/bin/env node
2
+ import ts from "typescript";
3
+ import {
4
+ existsSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ statSync,
8
+ realpathSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { execFileSync } from "node:child_process";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ const layout =
15
+ /^(?:padding|margin)(?:Top|Right|Bottom|Left|Horizontal|Vertical|Start|End|Inline(?:Start|End)?|Block(?:Start|End)?)?$|^(?:(?:min|max)?(?:Width|Height)|width|height|size|fontSize|gap|rowGap|columnGap|p|px|py|pt|pr|pb|pl|m|mx|my|mt|mr|mb|ml|w|h|borderRadius|borderTopLeftRadius|borderTopRightRadius|borderBottomLeftRadius|borderBottomRightRadius)$/;
16
+ const raw = new Set([
17
+ "Pressable",
18
+ "TouchableOpacity",
19
+ "TouchableHighlight",
20
+ "TouchableWithoutFeedback",
21
+ "TextInput",
22
+ "button",
23
+ "input",
24
+ "select",
25
+ "textarea",
26
+ ]);
27
+ const controls = new Set([...raw, "Button", "Input", "TamaguiButton"]);
28
+ const svgPrimitives = new Set([
29
+ "Circle",
30
+ "ClipPath",
31
+ "Defs",
32
+ "Ellipse",
33
+ "ForeignObject",
34
+ "G",
35
+ "Image",
36
+ "Line",
37
+ "LinearGradient",
38
+ "Marker",
39
+ "Mask",
40
+ "Path",
41
+ "Pattern",
42
+ "Polygon",
43
+ "Polyline",
44
+ "RadialGradient",
45
+ "Rect",
46
+ "Stop",
47
+ "Symbol",
48
+ "Text",
49
+ "TextPath",
50
+ "TSpan",
51
+ "Use",
52
+ ]);
53
+ const svgRoots = new Set(["Svg", "Symbol"]);
54
+ const tokenFactories = new Set([
55
+ "createTokens",
56
+ "createTamagui",
57
+ "createTheme",
58
+ "createThemes",
59
+ "createFont",
60
+ ]);
61
+ export const designRules = [
62
+ "hex",
63
+ "geometry",
64
+ "inline-spacing",
65
+ "raw-control",
66
+ "styled-mpo",
67
+ "token-file",
68
+ ];
69
+ export function inspectDesign(source, filename) {
70
+ const file = ts.createSourceFile(
71
+ filename,
72
+ source,
73
+ ts.ScriptTarget.Latest,
74
+ true,
75
+ ts.ScriptKind.TSX,
76
+ );
77
+ const options = {
78
+ noLib: true,
79
+ noResolve: true,
80
+ types: [],
81
+ jsx: ts.JsxEmit.Preserve,
82
+ };
83
+ const host = ts.createCompilerHost(options);
84
+ host.getSourceFile = (requested) => (requested === filename ? file : undefined);
85
+ host.fileExists = (requested) => requested === filename;
86
+ host.readFile = (requested) => (requested === filename ? source : undefined);
87
+ host.directoryExists = () => false;
88
+ const checker = ts.createProgram([filename], options, host).getTypeChecker();
89
+ const errors = [];
90
+ function importedFrom(node, test) {
91
+ const value = resolve(node);
92
+ const root = ts.isPropertyAccessExpression(value) ? value.expression : value;
93
+ const declarations = checker.getSymbolAtLocation(root)?.declarations ?? [];
94
+ return declarations.some((declaration) => {
95
+ let current = declaration;
96
+ while (current && !ts.isImportDeclaration(current)) current = current.parent;
97
+ return current && test(current.moduleSpecifier.text);
98
+ });
99
+ }
100
+ const mpoComponent = (node) =>
101
+ importedFrom(node, (specifier) => specifier.startsWith("@multiplatform.one/"));
102
+ const fromSvg = (node) =>
103
+ importedFrom(node, (specifier) => /^(?:@tamagui\/)?react-native-svg$/.test(specifier));
104
+ function svgTag(tag, set) {
105
+ if (ts.isIdentifier(tag) && /^[a-z]/.test(tag.text))
106
+ return set.has(tag.text[0].toUpperCase() + tag.text.slice(1));
107
+ return set.has(referenceName(tag)) && fromSvg(tag);
108
+ }
109
+ function carriesViewBox(node) {
110
+ const value = resolve(node);
111
+ if (!ts.isObjectLiteralExpression(value)) return true;
112
+ return value.properties.some((prop) =>
113
+ ts.isSpreadAssignment(prop)
114
+ ? carriesViewBox(prop.expression)
115
+ : !!prop.name && name(prop.name) === "viewBox",
116
+ );
117
+ }
118
+ function hasViewBox(element) {
119
+ return element.attributes.properties.some((attribute) =>
120
+ ts.isJsxSpreadAttribute(attribute)
121
+ ? carriesViewBox(attribute.expression)
122
+ : name(attribute.name) === "viewBox",
123
+ );
124
+ }
125
+ function svgArtwork(element) {
126
+ if (!svgTag(element.tagName, svgPrimitives)) return false;
127
+ for (let current = element.parent; current; current = current.parent) {
128
+ const opening = ts.isJsxElement(current) ? current.openingElement : undefined;
129
+ if (opening && opening !== element && svgTag(opening.tagName, svgRoots))
130
+ return hasViewBox(opening);
131
+ }
132
+ return true;
133
+ }
134
+ function name(node) {
135
+ return ts.isPropertyAccessExpression(node)
136
+ ? node.name.text
137
+ : node.getText(file).replace(/["']/g, "");
138
+ }
139
+ function resolve(node, seen = new Set()) {
140
+ if (
141
+ ts.isParenthesizedExpression(node) ||
142
+ ts.isAsExpression(node) ||
143
+ ts.isSatisfiesExpression(node)
144
+ )
145
+ return resolve(node.expression, seen);
146
+ const declaration = ts.isIdentifier(node)
147
+ ? checker.getSymbolAtLocation(node)?.valueDeclaration
148
+ : undefined;
149
+ if (
150
+ declaration &&
151
+ ts.isVariableDeclaration(declaration) &&
152
+ declaration.initializer &&
153
+ !seen.has(declaration)
154
+ ) {
155
+ seen.add(declaration);
156
+ return resolve(declaration.initializer, seen);
157
+ }
158
+ return node;
159
+ }
160
+ function numeric(node) {
161
+ const value = resolve(node);
162
+ if (ts.isNumericLiteral(value)) return true;
163
+ if (ts.isStringLiteral(value)) return /^-?\d+(?:\.\d+)?(?:px|rem|em)?$/.test(value.text);
164
+ if (ts.isPrefixUnaryExpression(value)) return numeric(value.operand);
165
+ if (ts.isBinaryExpression(value)) {
166
+ if (
167
+ [
168
+ ts.SyntaxKind.AmpersandAmpersandToken,
169
+ ts.SyntaxKind.BarBarToken,
170
+ ts.SyntaxKind.QuestionQuestionToken,
171
+ ].includes(value.operatorToken.kind)
172
+ )
173
+ return numeric(value.left) || numeric(value.right);
174
+ return numeric(value.left) && numeric(value.right);
175
+ }
176
+ if (ts.isConditionalExpression(value))
177
+ return numeric(value.whenTrue) || numeric(value.whenFalse);
178
+ return false;
179
+ }
180
+ function referenceName(node) {
181
+ const value = resolve(node);
182
+ const declaration = ts.isIdentifier(value)
183
+ ? checker.getSymbolAtLocation(value)?.declarations?.[0]
184
+ : undefined;
185
+ if (declaration && ts.isImportSpecifier(declaration))
186
+ return (declaration.propertyName ?? declaration.name).text;
187
+ return name(value);
188
+ }
189
+ function spacing(node) {
190
+ const value = resolve(node);
191
+ if (ts.isArrayLiteralExpression(value)) return value.elements.some((item) => spacing(item));
192
+ if (ts.isObjectLiteralExpression(value))
193
+ return value.properties.some((prop) => {
194
+ if (ts.isSpreadAssignment(prop)) return spacing(prop.expression);
195
+ return (
196
+ !!prop.name &&
197
+ layout.test(name(prop.name)) &&
198
+ !["fontSize", "size"].includes(name(prop.name))
199
+ );
200
+ });
201
+ if (ts.isBinaryExpression(value)) return spacing(value.left) || spacing(value.right);
202
+ if (ts.isConditionalExpression(value))
203
+ return spacing(value.whenTrue) || spacing(value.whenFalse);
204
+ return false;
205
+ }
206
+ function report(node, rule, message) {
207
+ const { line, character } = file.getLineAndCharacterOfPosition(node.getStart(file));
208
+ errors.push(`${filename}:${line + 1}:${character + 1}: [${rule}] ${message}`);
209
+ }
210
+ let tokenFile = false;
211
+ if (/^(?:design-)?tokens\.[cm]?[jt]sx?$/.test(path.basename(filename))) {
212
+ tokenFile = true;
213
+ report(file, "token-file", "Local token files are forbidden; tokens live in MPO.");
214
+ }
215
+ function inspectProps(node, seen = new Set(), deep = false) {
216
+ const value = resolve(node);
217
+ if (seen.has(value)) return;
218
+ seen.add(value);
219
+ if (ts.isArrayLiteralExpression(value))
220
+ value.elements.forEach((item) => inspectProps(item, seen, deep));
221
+ if (ts.isConditionalExpression(value)) {
222
+ inspectProps(value.whenTrue, seen, deep);
223
+ inspectProps(value.whenFalse, seen, deep);
224
+ }
225
+ if (ts.isBinaryExpression(value)) {
226
+ if (
227
+ value.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
228
+ value.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken
229
+ )
230
+ inspectProps(value.left, seen, deep);
231
+ inspectProps(value.right, seen, deep);
232
+ }
233
+ if (ts.isObjectLiteralExpression(value))
234
+ value.properties.forEach((prop) => {
235
+ if (ts.isSpreadAssignment(prop)) inspectProps(prop.expression, seen, deep);
236
+ if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
237
+ const initializer = ts.isShorthandPropertyAssignment(prop)
238
+ ? checker.getShorthandAssignmentValueSymbol(prop)?.valueDeclaration?.initializer
239
+ : prop.initializer;
240
+ if (!initializer) return;
241
+ const key = name(prop.name);
242
+ if (layout.test(key) && numeric(initializer))
243
+ report(prop, "geometry", `${name(prop.name)} requires a token, not a numeric literal.`);
244
+ if (/style$/i.test(key) && spacing(initializer))
245
+ report(prop, "inline-spacing", "Inline spacing styles are forbidden; use token props.");
246
+ if (deep || key.startsWith("$") || /style$/i.test(key))
247
+ inspectProps(initializer, seen, deep);
248
+ }
249
+ });
250
+ }
251
+ function visit(node) {
252
+ if (
253
+ !tokenFile &&
254
+ ts.isCallExpression(node) &&
255
+ tokenFactories.has(referenceName(node.expression))
256
+ ) {
257
+ tokenFile = true;
258
+ report(node, "token-file", "Local token files are forbidden; tokens live in MPO.");
259
+ }
260
+ if (
261
+ (ts.isStringLiteralLike(node) ||
262
+ ts.isTemplateHead(node) ||
263
+ ts.isTemplateMiddle(node) ||
264
+ ts.isTemplateTail(node)) &&
265
+ /#[\da-fA-F]{3,8}\b/.test(node.text) &&
266
+ !/^\s*\/\/ hex-escape: \S.+$/m.test(source)
267
+ ) {
268
+ report(node, "hex", "Hex colours require a theme token.");
269
+ }
270
+ if (
271
+ ts.isCallExpression(node) &&
272
+ referenceName(node.expression) === "styled" &&
273
+ node.arguments[0] &&
274
+ (controls.has(referenceName(node.arguments[0])) || mpoComponent(node.arguments[0]))
275
+ ) {
276
+ report(
277
+ node,
278
+ "styled-mpo",
279
+ "Use the MPO component directly; styled MPO wrappers are forbidden.",
280
+ );
281
+ }
282
+ if (
283
+ ts.isCallExpression(node) &&
284
+ (referenceName(node.expression) === "styled" ||
285
+ node.expression.getText(file) === "StyleSheet.create")
286
+ )
287
+ node.arguments
288
+ .slice(referenceName(node.expression) === "styled" ? 1 : 0)
289
+ .forEach((arg) => inspectProps(arg, new Set(), true));
290
+ if (
291
+ ts.isCallExpression(node) &&
292
+ referenceName(node.expression) === "createElement" &&
293
+ node.arguments[0] &&
294
+ raw.has(referenceName(node.arguments[0]))
295
+ )
296
+ report(node, "raw-control", "Use an MPO control instead of a raw control.");
297
+ if (
298
+ ts.isCallExpression(node) &&
299
+ referenceName(node.expression) === "createElement" &&
300
+ node.arguments[1]
301
+ )
302
+ inspectProps(node.arguments[1]);
303
+ if (ts.isJsxSpreadAttribute(node) && !svgArtwork(node.parent.parent))
304
+ inspectProps(node.expression);
305
+ if (
306
+ (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) &&
307
+ raw.has(referenceName(node.tagName))
308
+ ) {
309
+ report(node, "raw-control", "Use an MPO control instead of a raw control.");
310
+ }
311
+ if (ts.isJsxAttribute(node) && node.initializer) {
312
+ const key = name(node.name);
313
+ const value = ts.isJsxExpression(node.initializer)
314
+ ? node.initializer.expression
315
+ : node.initializer;
316
+ if (value && (key.startsWith("$") || /style$/i.test(key))) inspectProps(value);
317
+ if (value && layout.test(key) && numeric(value) && !svgArtwork(node.parent.parent))
318
+ report(node, "geometry", `${key} requires a token, not a numeric literal.`);
319
+ if (value && /style$/i.test(key) && spacing(value))
320
+ report(node, "inline-spacing", "Inline spacing styles are forbidden; use token props.");
321
+ if (
322
+ value &&
323
+ ["render", "as", "tag"].includes(key) &&
324
+ ts.isStringLiteral(value) &&
325
+ raw.has(value.text)
326
+ )
327
+ report(node, "raw-control", "Use an MPO control instead of a raw control tag.");
328
+ }
329
+ ts.forEachChild(node, visit);
330
+ }
331
+ visit(file);
332
+ return errors;
333
+ }
334
+
335
+ const ignoredDirectories = new Set([
336
+ "node_modules",
337
+ "public",
338
+ "dist",
339
+ "build",
340
+ "bundle",
341
+ "coverage",
342
+ "vendor",
343
+ "ios",
344
+ "android",
345
+ "tests",
346
+ "__tests__",
347
+ "__fixtures__",
348
+ "scripts",
349
+ ]);
350
+
351
+ export function designFiles(input) {
352
+ const root = path.resolve(input);
353
+ const info = statSync(root);
354
+ const eligible = (filename) =>
355
+ /\.[cm]?[jt]sx?$/.test(filename) &&
356
+ !/\.(?:test|spec|stories|config|gen|generated|d)\.[cm]?[jt]sx?$/.test(filename);
357
+ if (info.isFile()) return eligible(root) ? [root] : [];
358
+ let candidates;
359
+ try {
360
+ candidates = execFileSync(
361
+ "git",
362
+ ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "."],
363
+ {
364
+ cwd: root,
365
+ encoding: "utf8",
366
+ stdio: ["ignore", "pipe", "ignore"],
367
+ },
368
+ )
369
+ .split("\0")
370
+ .filter(Boolean);
371
+ } catch {
372
+ candidates = [];
373
+ function walk(dir) {
374
+ for (const entry of readdirSync(path.join(root, dir), { withFileTypes: true })) {
375
+ if (entry.name.startsWith(".") || ignoredDirectories.has(entry.name)) continue;
376
+ const relative = path.join(dir, entry.name);
377
+ if (entry.isDirectory()) walk(relative);
378
+ else if (entry.isFile()) candidates.push(relative);
379
+ }
380
+ }
381
+ walk("");
382
+ }
383
+ return [...new Set(candidates)]
384
+ .filter(
385
+ (filename) =>
386
+ eligible(filename) &&
387
+ !filename
388
+ .split(path.sep)
389
+ .some((part) => part.startsWith(".") || ignoredDirectories.has(part)),
390
+ )
391
+ .map((filename) => path.join(root, filename))
392
+ .filter((filename) => {
393
+ try {
394
+ return statSync(filename).isFile();
395
+ } catch {
396
+ return false;
397
+ }
398
+ })
399
+ .sort();
400
+ }
401
+
402
+ export const defaultScope = ["apps", "features", "packages"];
403
+
404
+ const ruleOf = (error) => /: \[([a-z-]+)\] /.exec(error)?.[1] ?? "unknown";
405
+
406
+ export function countDesign(errors) {
407
+ const rules = Object.fromEntries(designRules.map((rule) => [rule, 0]));
408
+ for (const error of errors) rules[ruleOf(error)] = (rules[ruleOf(error)] ?? 0) + 1;
409
+ return { total: errors.length, rules };
410
+ }
411
+
412
+ export function scanDesign(paths) {
413
+ const files = [...new Set(paths.flatMap(designFiles))];
414
+ if (!files.length) throw new Error("No application source files found in the supplied paths.");
415
+ const errors = files.flatMap((filename) =>
416
+ inspectDesign(readFileSync(filename, "utf8"), filename),
417
+ );
418
+ return { files, errors, ...countDesign(errors) };
419
+ }
420
+
421
+ function parseArgs(args) {
422
+ const options = { paths: [], baselines: [], frozen: false, init: false, list: false };
423
+ for (let index = 0; index < args.length; index++) {
424
+ const arg = args[index];
425
+ if (arg === "--baseline") {
426
+ if (!args[index + 1]) throw new Error("--baseline needs a file.");
427
+ options.baselines.push(args[++index]);
428
+ } else if (arg === "--frozen") options.frozen = true;
429
+ else if (arg === "--init") options.init = true;
430
+ else if (arg === "--list") options.list = true;
431
+ else if (arg.startsWith("--")) throw new Error(`Unknown option ${arg}.`);
432
+ else options.paths.push(arg);
433
+ }
434
+ if (options.paths.length && options.baselines.length > 1)
435
+ throw new Error("Pass paths with at most one --baseline.");
436
+ return options;
437
+ }
438
+
439
+ function summary(scan) {
440
+ const rules = Object.entries(scan.rules)
441
+ .filter(([, count]) => count)
442
+ .map(([rule, count]) => `${rule} ${count}`)
443
+ .join(", ");
444
+ return `${scan.total} violations in ${scan.files.length} files${rules ? ` (${rules})` : ""}`;
445
+ }
446
+
447
+ function scopeFrom(base) {
448
+ const scope = defaultScope.filter((dir) => existsSync(path.join(base, dir)));
449
+ if (!scope.length)
450
+ throw new Error(`No ${defaultScope.join(", ")} directory under ${base}; pass paths.`);
451
+ return scope;
452
+ }
453
+
454
+ function ratchet(file, options) {
455
+ const real = (input) => (existsSync(input) ? realpathSync(input) : path.resolve(input));
456
+ const base = real(path.dirname(path.resolve(file)));
457
+ const relative = (input) => path.relative(base, real(input)) || ".";
458
+ const present = existsSync(file);
459
+ if (!present && !options.init)
460
+ throw new Error(`${file} does not exist; create it with --init and commit it.`);
461
+ const previous = present ? JSON.parse(readFileSync(file, "utf8")) : undefined;
462
+ const paths = options.paths.length
463
+ ? options.paths.map(relative)
464
+ : (previous?.paths ?? scopeFrom(process.cwd()).map(relative));
465
+ const scan = scanDesign(paths.map((input) => path.resolve(base, input)));
466
+ const next = {
467
+ paths,
468
+ total: scan.total,
469
+ rules: Object.fromEntries(Object.entries(scan.rules).sort()),
470
+ };
471
+ const write = () => writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
472
+ if (!previous) {
473
+ write();
474
+ process.stdout.write(`MPO design guard: ${file} created at ${summary(scan)}.\n`);
475
+ return 0;
476
+ }
477
+ const rules = [...new Set([...Object.keys(previous.rules ?? {}), ...Object.keys(next.rules)])];
478
+ const rose = rules.filter((rule) => (next.rules[rule] ?? 0) > (previous.rules?.[rule] ?? 0));
479
+ const fell = rules.filter((rule) => (next.rules[rule] ?? 0) < (previous.rules?.[rule] ?? 0));
480
+ if (rose.length) {
481
+ process.stderr.write(
482
+ `${scan.errors.join("\n")}\nMPO design guard: ${file} regressed, ${rose
483
+ .map((rule) => `${rule} ${previous.rules?.[rule] ?? 0} -> ${next.rules[rule]}`)
484
+ .join(", ")}. ${summary(scan)}.\n`,
485
+ );
486
+ return 1;
487
+ }
488
+ if (options.list && scan.errors.length) process.stderr.write(`${scan.errors.join("\n")}\n`);
489
+ if (fell.length) {
490
+ const change = `${previous.total} -> ${next.total}`;
491
+ if (options.frozen) {
492
+ process.stderr.write(
493
+ `MPO design guard: ${file} is stale (${change}); run the lint without --frozen and commit the lowered baseline.\n`,
494
+ );
495
+ return 1;
496
+ }
497
+ write();
498
+ process.stdout.write(`MPO design guard: ${file} ratcheted down ${change}. ${summary(scan)}.\n`);
499
+ return 0;
500
+ }
501
+ process.stdout.write(`MPO design guard: ${file} holds at ${summary(scan)}.\n`);
502
+ return 0;
503
+ }
504
+
505
+ export function runDesignLint(args = process.argv.slice(2)) {
506
+ try {
507
+ const options = parseArgs(args);
508
+ if (options.baselines.length)
509
+ return options.baselines.reduce((status, file) => ratchet(file, options) || status, 0);
510
+ const scan = scanDesign(options.paths.length ? options.paths : scopeFrom(process.cwd()));
511
+ if (scan.errors.length) {
512
+ process.stderr.write(`${scan.errors.join("\n")}\nMPO design guard: ${summary(scan)}.\n`);
513
+ return 1;
514
+ }
515
+ process.stdout.write(`MPO design guard passed (${scan.files.length} source files).\n`);
516
+ return 0;
517
+ } catch (error) {
518
+ process.stderr.write(`MPO design guard: ${error.message}\n`);
519
+ return 1;
520
+ }
521
+ }
522
+
523
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))
524
+ process.exitCode = runDesignLint();
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { t as createViteConfig } from "./vite-n3XSaftn.js";
2
- import { i as phosphorTokenColorPlugin, n as tamaguiWorkspacePathsFile, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-B6aRC2BU.js";
3
- import { t as createStorybookViteConfig } from "./storybook-Dxwh_fl2.js";
1
+ import { t as createViteConfig } from "./vite-DkidIRYX.js";
2
+ import { i as phosphorTokenColorPlugin, n as tamaguiWorkspacePathsFile, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-DR3dud79.js";
3
+ import { t as createStorybookViteConfig } from "./storybook-Ba0KCHBo.js";
4
4
  import { createVitestConfig } from "./vitest.js";
5
5
 
6
6
  export { createStorybookViteConfig, createViteConfig, createVitestConfig, ensureTamaguiWorkspacePaths, phosphorTokenColorPlugin, tamaguiWorkspacePathsFile };
@@ -1,4 +1,4 @@
1
- import { i as phosphorTokenColorPlugin, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-B6aRC2BU.js";
1
+ import { i as phosphorTokenColorPlugin, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-DR3dud79.js";
2
2
  import { i as withSingletonDepAliases, r as resolvePackageMainSource, t as discoverPublicPackageRoots } from "./workspacePublicPackages-ClSmVngx.js";
3
3
  import { createRequire } from "node:module";
4
4
  import fs from "node:fs";
@@ -246,11 +246,18 @@ function mergePackageResolveAliases(pkgDir, pkgName, pkgJson, aliases) {
246
246
  if (fs.existsSync(fullParentDir) && fs.statSync(fullParentDir).isDirectory()) aliases[`${pkgName}/${parentDir}`] = fullParentDir;
247
247
  }
248
248
  } else {
249
- const resolved = resolveSubpathSource(pkgDir, subpath);
249
+ const resolved = resolveSubpathSource(pkgDir, subpath) ?? exportSourceTarget(pkgDir, pkgJson.exports[exportKey]);
250
250
  if (resolved) aliases[`${pkgName}/${subpath}`] = resolved;
251
251
  }
252
252
  }
253
253
  }
254
+ /** An export entry's `source` file, for subpaths whose name is not their path under src/. */
255
+ function exportSourceTarget(pkgDir, entry) {
256
+ const source = entry?.source;
257
+ if (typeof source !== "string") return void 0;
258
+ const file = path.join(pkgDir, source);
259
+ return fs.existsSync(file) && fs.statSync(file).isFile() ? file : void 0;
260
+ }
254
261
  function resolveSubpathSource(pkgDir, subpath) {
255
262
  const candidates = [
256
263
  path.join(pkgDir, "src", `${subpath}.storybook.ts`),
package/lib/storybook.js CHANGED
@@ -1,3 +1,3 @@
1
- import { t as createStorybookViteConfig } from "./storybook-Dxwh_fl2.js";
1
+ import { t as createStorybookViteConfig } from "./storybook-Ba0KCHBo.js";
2
2
 
3
3
  export { createStorybookViteConfig };
@@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process";
6
6
 
7
7
  //#region src/phosphorTokenColor.ts
8
8
  const virtualPrefix = "\0mpo-phosphor-token-icon-base:";
9
+ const virtualSuffix = "?mpo-token-icon";
9
10
  const iconBaseFilter = /IconBase\.es\.js$/;
10
11
  const phosphorLibFile = /\/@phosphor-icons\/react\/dist\/lib\/IconBase\.es\.js$/;
11
12
  const phosphorBarrelFile = /\/@phosphor-icons\/react\/dist\/index\.es\.js$/;
@@ -57,10 +58,10 @@ function resolvePhosphorIconBase(id, importer) {
57
58
  if (!importer || importer.startsWith("\0")) return null;
58
59
  if (!toPosix(importer).includes("/@phosphor-icons/react/dist/")) return null;
59
60
  const target = toPosix(id.startsWith(".") ? path.resolve(path.dirname(importer), id) : id);
60
- return phosphorLibFile.test(target) ? virtualPrefix + target : null;
61
+ return phosphorLibFile.test(target) ? virtualPrefix + target + virtualSuffix : null;
61
62
  }
62
63
  function loadTokenColorIconBase(id) {
63
- return id.startsWith(virtualPrefix) ? tokenColorIconBaseSource(id.slice(30)) : null;
64
+ return id.startsWith(virtualPrefix) ? tokenColorIconBaseSource(id.slice(30, -15)) : null;
64
65
  }
65
66
  function rolldownTokenColorPlugin() {
66
67
  return {
@@ -1,4 +1,4 @@
1
- import { i as phosphorTokenColorPlugin, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-B6aRC2BU.js";
1
+ import { i as phosphorTokenColorPlugin, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-DR3dud79.js";
2
2
  import { i as withSingletonDepAliases, t as discoverPublicPackageRoots } from "./workspacePublicPackages-ClSmVngx.js";
3
3
  import { createRequire } from "node:module";
4
4
  import fs from "node:fs";
package/lib/vite.js CHANGED
@@ -1,5 +1,5 @@
1
- import { n as publicConfigPlugin, r as readPublicConfigKeys, t as createViteConfig } from "./vite-n3XSaftn.js";
2
- import { i as phosphorTokenColorPlugin } from "./tamaguiWorkspacePaths-B6aRC2BU.js";
1
+ import { n as publicConfigPlugin, r as readPublicConfigKeys, t as createViteConfig } from "./vite-DkidIRYX.js";
2
+ import { i as phosphorTokenColorPlugin } from "./tamaguiWorkspacePaths-DR3dud79.js";
3
3
  import { n as publicPackageViteSourceAliases, t as discoverPublicPackageRoots } from "./workspacePublicPackages-ClSmVngx.js";
4
4
 
5
5
  export { createViteConfig, discoverPublicPackageRoots, phosphorTokenColorPlugin, publicConfigPlugin, publicPackageViteSourceAliases, readPublicConfigKeys };
package/lib/vitest.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as phosphorTokenColorPlugin, r as phosphorBarrelBundlePlugin, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-B6aRC2BU.js";
1
+ import { i as phosphorTokenColorPlugin, r as phosphorBarrelBundlePlugin, t as ensureTamaguiWorkspacePaths } from "./tamaguiWorkspacePaths-DR3dud79.js";
2
2
  import Module from "node:module";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";