@multiplatform.one/config 7.45.1 → 7.47.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
@@ -148,8 +148,13 @@ outline, box shadow, shadow or elevation is reported as `field-focus`. Focus
148
148
  looks come from MPO: fields take `FIELD_FOCUS_STYLE` (the accent border), other
149
149
  controls the theme's focus ring. Zeroing those keys is allowed.
150
150
 
151
- Every violation is tagged with its rule: `field-focus`, `hex`, `geometry`,
152
- `inline-spacing`, `raw-control`, `styled-mpo` or `token-file`.
151
+ An icon-only button laid in a row beside a field (`Input`, `Select`, an
152
+ `Input.Box` and the other MPO fields) is reported as `field-action` (LC-149). A
153
+ field's own action, such as reveal, clear, copy or an eyedropper, is
154
+ `Input.Action` inside the field's trailing slot.
155
+
156
+ Every violation is tagged with its rule: `field-action`, `field-focus`, `hex`,
157
+ `geometry`, `inline-spacing`, `raw-control`, `styled-mpo` or `token-file`.
153
158
 
154
159
  #### Ratchet
155
160
 
package/design-lint.mjs CHANGED
@@ -56,6 +56,13 @@ const svgRoots = new Set(["Svg", "Symbol"]);
56
56
  const focusPaint =
57
57
  /^(?:outline(?:Width|Color|Offset)?|boxShadow|shadow(?:Color|Opacity|Radius|Offset)|elevation)$/;
58
58
  const focusStates = new Set(["focusStyle", "focusVisibleStyle", "focusWithinStyle"]);
59
+ // LC-149 FIELD-TRAILING-ACTION: a field's own action (reveal, clear, copy,
60
+ // eyedropper) is Input.Action inside the field, never an icon button beside it.
61
+ const fieldTag =
62
+ /^(?:Input|InputParts|FormsInput|TextArea|SearchInput|CopyField|Password|PasswordInput|Select|Combobox|DatePicker|TimePicker|DatetimePicker|PhoneInput|Stepper|NumberInput|Duration)$|\.Box$/;
63
+ const actionTag =
64
+ /^(?:Button|IconButton|TamaguiButton|Pressable|TouchableOpacity|TouchableHighlight|button)$|\.Button$/;
65
+ const rowTag = /^(?:XStack|XGroup)$|\.XGroup$/;
59
66
  const tokenFactories = new Set([
60
67
  "createTokens",
61
68
  "createTamagui",
@@ -64,6 +71,7 @@ const tokenFactories = new Set([
64
71
  "createFont",
65
72
  ]);
66
73
  export const designRules = [
74
+ "field-action",
67
75
  "field-focus",
68
76
  "hex",
69
77
  "geometry",
@@ -266,6 +274,68 @@ export function inspectDesign(source, filename, projectRoot = process.cwd()) {
266
274
  `${key} paints its own ${name(painted.name)}; focus comes from MPO (FIELD_FOCUS_STYLE).`,
267
275
  );
268
276
  }
277
+ function tagText(tag) {
278
+ return ts.isIdentifier(tag) ? referenceName(tag) : tag.getText(file);
279
+ }
280
+ function opening(element) {
281
+ return ts.isJsxElement(element) ? element.openingElement : element;
282
+ }
283
+ function attribute(element, key) {
284
+ return opening(element).attributes.properties.find(
285
+ (attr) => ts.isJsxAttribute(attr) && name(attr.name) === key,
286
+ );
287
+ }
288
+ function iconOnly(element) {
289
+ const children = ts.isJsxElement(element) ? element.children : [];
290
+ const hasText = children.some(
291
+ (child) =>
292
+ (ts.isJsxText(child) && child.text.trim() !== "") ||
293
+ ((ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) &&
294
+ !tagText(opening(child).tagName).endsWith("Icon")) ||
295
+ (ts.isJsxExpression(child) &&
296
+ !!child.expression &&
297
+ !ts.isJsxElement(child.expression) &&
298
+ !ts.isJsxSelfClosingElement(child.expression)),
299
+ );
300
+ if (hasText) return false;
301
+ if (attribute(element, "icon")) return true;
302
+ return children.some(
303
+ (child) =>
304
+ (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) &&
305
+ tagText(opening(child).tagName).endsWith("Icon"),
306
+ );
307
+ }
308
+ function isRow(element) {
309
+ if (rowTag.test(tagText(opening(element).tagName))) return true;
310
+ const direction = attribute(element, "flexDirection");
311
+ return !!direction?.initializer && /["']row["']/.test(direction.initializer.getText(file));
312
+ }
313
+ function checkFieldAction(element) {
314
+ if (!isRow(element)) return;
315
+ const children = element.children.flatMap((child) => {
316
+ if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) return [child];
317
+ if (
318
+ ts.isJsxExpression(child) &&
319
+ child.expression &&
320
+ ts.isBinaryExpression(child.expression) &&
321
+ child.expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
322
+ ) {
323
+ const right = child.expression.right;
324
+ const inner = ts.isParenthesizedExpression(right) ? right.expression : right;
325
+ if (ts.isJsxElement(inner) || ts.isJsxSelfClosingElement(inner)) return [inner];
326
+ }
327
+ return [];
328
+ });
329
+ if (!children.some((child) => fieldTag.test(tagText(opening(child).tagName)))) return;
330
+ for (const child of children) {
331
+ if (actionTag.test(tagText(opening(child).tagName)) && iconOnly(child))
332
+ report(
333
+ child,
334
+ "field-action",
335
+ "A field's own action is Input.Action inside the field, not a button beside it.",
336
+ );
337
+ }
338
+ }
269
339
  function report(node, rule, message) {
270
340
  const { line, character } = file.getLineAndCharacterOfPosition(node.getStart(file));
271
341
  errors.push(`${filename}:${line + 1}:${character + 1}: [${rule}] ${message}`);
@@ -325,6 +395,7 @@ export function inspectDesign(source, filename, projectRoot = process.cwd()) {
325
395
  "raw-icon",
326
396
  "Wrap Phosphor icons in themed() before rendering, or pass the component to an MPO icon slot.",
327
397
  );
398
+ if (ts.isJsxElement(node)) checkFieldAction(node);
328
399
  if (
329
400
  !tokenFile &&
330
401
  ts.isCallExpression(node) &&
package/lint.mjs CHANGED
@@ -1296,11 +1296,74 @@ function checkReactNativeWebExports() {
1296
1296
  return { ok: true, violations };
1297
1297
  }
1298
1298
 
1299
+ const REGEX_PRECEDING_KEYWORDS = new Set([
1300
+ "return",
1301
+ "typeof",
1302
+ "case",
1303
+ "in",
1304
+ "of",
1305
+ "void",
1306
+ "delete",
1307
+ "throw",
1308
+ "yield",
1309
+ "await",
1310
+ "else",
1311
+ "do",
1312
+ ]);
1313
+
1314
+ /**
1315
+ * True when the `/` at `index` opens a regex literal rather than dividing.
1316
+ * @param {string} src
1317
+ * @param {number} index
1318
+ */
1319
+ function startsRegex(src, index) {
1320
+ let at = index - 1;
1321
+ while (at >= 0 && /\s/.test(src[at])) at--;
1322
+ if (at < 0) return true;
1323
+ const prev = src[at];
1324
+ if (/[\w$]/.test(prev)) {
1325
+ let start = at;
1326
+ while (start > 0 && /[\w$]/.test(src[start - 1])) start--;
1327
+ return REGEX_PRECEDING_KEYWORDS.has(src.slice(start, at + 1));
1328
+ }
1329
+ return !/[)\]"'`]/.test(prev);
1330
+ }
1331
+
1332
+ /**
1333
+ * The offset just past the regex literal that opens at `index`, flags
1334
+ * included. A line break before the closing `/` means it was not a regex
1335
+ * after all, and the scan resumes right after the `/`.
1336
+ * @param {string} src
1337
+ * @param {number} index
1338
+ */
1339
+ function regexEnd(src, index) {
1340
+ let i = index + 1;
1341
+ let inClass = false;
1342
+ while (i < src.length) {
1343
+ const char = src[i];
1344
+ if (char === "\n") return index + 1;
1345
+ if (char === "\\") {
1346
+ i += 2;
1347
+ continue;
1348
+ }
1349
+ if (char === "[") inClass = true;
1350
+ else if (char === "]") inClass = false;
1351
+ else if (char === "/" && !inClass) break;
1352
+ i++;
1353
+ }
1354
+ i++;
1355
+ while (i < src.length && /[a-z]/i.test(src[i])) i++;
1356
+ return i;
1357
+ }
1358
+
1299
1359
  /**
1300
- * Offsets in `src` that are live CODE — not inside a comment, a string or a
1301
- * template literal. One left-to-right pass. Regex literals are treated as
1302
- * code, and a `${…}` interpolation is treated as string, which can only ever
1303
- * make this check miss something, never invent a violation.
1360
+ * Offsets in `src` that are live CODE — not inside a comment, a string, a
1361
+ * template literal or a regex literal. One left-to-right pass. A `${…}`
1362
+ * interpolation is treated as string, which can only ever make a check miss
1363
+ * something. A regex literal has to be skipped: a quote inside one, as in
1364
+ * `/^['"]/`, would otherwise open a string and flip the mask for the rest of
1365
+ * the file. A `/` starts a regex when the code before it cannot end an
1366
+ * expression (an operator, an opening bracket, a keyword such as `return`).
1304
1367
  *
1305
1368
  * @param {string} src
1306
1369
  * @returns {Uint8Array} 1 at every code offset
@@ -1321,6 +1384,10 @@ function codeOffsets(src) {
1321
1384
  i += 2;
1322
1385
  continue;
1323
1386
  }
1387
+ if (char === "/" && startsRegex(src, i)) {
1388
+ i = regexEnd(src, i);
1389
+ continue;
1390
+ }
1324
1391
  if (char === '"' || char === "'" || char === "`") {
1325
1392
  const quote = char;
1326
1393
  i++;
@@ -1405,6 +1472,364 @@ function checkImportMetaInPublicSource() {
1405
1472
  return violations;
1406
1473
  }
1407
1474
 
1475
+ // ── bare `instanceof` of a web-only global (FZ-7791) ──────────────────────
1476
+
1477
+ /**
1478
+ * Classes a browser defines and Hermes, GJS or a bare Node may not. Naming a
1479
+ * missing global is a ReferenceError, and `instanceof` names it even when the
1480
+ * value on the left is a plain object, so the throw lands before the code it
1481
+ * guards ever runs. React Native ships some of these (Blob, FormData, Headers);
1482
+ * they are listed anyway, because GJS and a consumer's own polyfill set differ.
1483
+ */
1484
+ const WEB_ONLY_CLASSES = [
1485
+ "ReadableStream",
1486
+ "WritableStream",
1487
+ "TransformStream",
1488
+ "TextDecoderStream",
1489
+ "TextEncoderStream",
1490
+ "CompressionStream",
1491
+ "DecompressionStream",
1492
+ "Blob",
1493
+ "File",
1494
+ "FileList",
1495
+ "FormData",
1496
+ "URLSearchParams",
1497
+ "Headers",
1498
+ "Request",
1499
+ "Response",
1500
+ "BroadcastChannel",
1501
+ "MessageChannel",
1502
+ "MessagePort",
1503
+ "DOMException",
1504
+ "Event",
1505
+ "EventTarget",
1506
+ "CustomEvent",
1507
+ "KeyboardEvent",
1508
+ "MouseEvent",
1509
+ "PointerEvent",
1510
+ "TouchEvent",
1511
+ "FocusEvent",
1512
+ "Node",
1513
+ "Element",
1514
+ "Document",
1515
+ "Window",
1516
+ "ShadowRoot",
1517
+ "DataTransfer",
1518
+ "Worker",
1519
+ "CryptoKey",
1520
+ ];
1521
+
1522
+ const WEB_ONLY_INSTANCEOF_RE = new RegExp(
1523
+ String.raw`\binstanceof\s+(${WEB_ONLY_CLASSES.join("|")}|HTML\w*Element|SVG\w*Element)\b(?!\s*\.)`,
1524
+ "g",
1525
+ );
1526
+
1527
+ /** Files that only ever load in a browser engine: a web, webview or extension twin. */
1528
+ const WEB_ONLY_FILE_RE = /\.(web|webext|tauri)\.(tsx|ts|jsx|js|mjs)$/;
1529
+ const WEB_ONLY_SCANNED_RE = /\.(tsx|ts|jsx|js|mjs|cjs|mts|cts)$/;
1530
+
1531
+ /** public/test-utils is the vitest DOM harness; it never ships into an app bundle. */
1532
+ const WEB_ONLY_EXEMPT_PACKAGES = new Set(["test-utils"]);
1533
+
1534
+ /**
1535
+ * The code that runs before `index` in its own block: from the innermost
1536
+ * unclosed `{` (or the top of the file) up to `index`, with every block that
1537
+ * opened and closed in between dropped. A `typeof X` there guards the
1538
+ * `instanceof X` after it, which covers the same-expression form,
1539
+ * `typeof Blob !== "undefined" && v instanceof Blob`, and an early return,
1540
+ * `if (typeof Element === "undefined") return false;`. A guard inside a
1541
+ * sibling callback or another function does not count.
1542
+ * @param {string} src
1543
+ * @param {Uint8Array} mask
1544
+ * @param {number} index
1545
+ */
1546
+ function blockBefore(src, mask, index) {
1547
+ let depth = 0;
1548
+ let kept = "";
1549
+ for (let at = index - 1; at >= 0; at--) {
1550
+ const char = src[at];
1551
+ if (mask[at] === 1 && char === "}") {
1552
+ depth++;
1553
+ continue;
1554
+ }
1555
+ if (mask[at] === 1 && char === "{") {
1556
+ if (depth === 0) break;
1557
+ depth--;
1558
+ continue;
1559
+ }
1560
+ if (depth === 0) kept = char + kept;
1561
+ }
1562
+ return kept;
1563
+ }
1564
+
1565
+ /**
1566
+ * True when the file binds `name` itself (an import, a declaration), so its
1567
+ * `instanceof name` is not the global at all: prosemirror's Node, a local File.
1568
+ * Only a binding in code counts, not one a comment mentions.
1569
+ * @param {string} src
1570
+ * @param {Uint8Array} mask
1571
+ * @param {string} name
1572
+ */
1573
+ function bindsName(src, mask, name) {
1574
+ const binding = new RegExp(
1575
+ String.raw`\bimport\s[^;]*\b${name}\b(?!\s+as\b)[^;]*\bfrom\b|\b(?:const|let|var|class|function)\s+${name}\b`,
1576
+ "g",
1577
+ );
1578
+ for (const match of src.matchAll(binding)) {
1579
+ if (mask[match.index ?? 0] === 1) return true;
1580
+ }
1581
+ return false;
1582
+ }
1583
+
1584
+ /**
1585
+ * `x instanceof ReadableStream` in a public package's runtime source, with no
1586
+ * `typeof ReadableStream` earlier in the same block (FZ-7791).
1587
+ *
1588
+ * HttpClient.request checked its body with a bare `instanceof ReadableStream`.
1589
+ * Hermes has no ReadableStream, so every POST and PUT with a JSON body threw
1590
+ * a ReferenceError before fetch on every native MPO app, while web, where the
1591
+ * global exists, stayed green. Guard it in place
1592
+ * (`typeof X !== "undefined" && v instanceof X`) or move it into a .web twin.
1593
+ *
1594
+ * Exempt: specs and stories, files that load only in a browser engine
1595
+ * (.web/.webext/.tauri), Node-only tooling (a value import of a `node:`
1596
+ * builtin), and a file that binds the name itself.
1597
+ */
1598
+ function checkWebOnlyInstanceof() {
1599
+ /** @type {string[]} */
1600
+ const violations = [];
1601
+ if (!existsSync(PUBLIC_DIR)) return violations;
1602
+ for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
1603
+ if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
1604
+ if (WEB_ONLY_EXEMPT_PACKAGES.has(pkg.name)) continue;
1605
+ walkFiles(join(PUBLIC_DIR, pkg.name, "src"), (filePath) => {
1606
+ if (!WEB_ONLY_SCANNED_RE.test(filePath) || filePath.endsWith(".d.ts")) return;
1607
+ if (/\.(spec|test|stories|story)\./.test(filePath) || WEB_ONLY_FILE_RE.test(filePath)) return;
1608
+ const src = readFileSync(filePath, "utf8");
1609
+ if (!src.includes("instanceof")) return;
1610
+ if (NODE_BUILTIN_IMPORT_RE.test(src)) return;
1611
+ const mask = codeOffsets(src);
1612
+ for (const match of src.matchAll(WEB_ONLY_INSTANCEOF_RE)) {
1613
+ const index = match.index ?? 0;
1614
+ if (mask[index] !== 1) continue;
1615
+ const name = match[1];
1616
+ if (bindsName(src, mask, name)) continue;
1617
+ const guard = new RegExp(String.raw`\btypeof\s+(?:globalThis\.)?${name}\b`);
1618
+ if (guard.test(blockBefore(src, mask, index))) continue;
1619
+ violations.push(`${relative(ROOT, filePath)}:${lineAt(src, index)} instanceof ${name}`);
1620
+ }
1621
+ });
1622
+ }
1623
+ return violations;
1624
+ }
1625
+
1626
+ // ── window/document DOM calls without a native guard (FZ-7799) ────────────
1627
+
1628
+ /**
1629
+ * Window members a browser defines and React Native's `window` does not. RN
1630
+ * makes `window` the global object, so `typeof window === "undefined"` passes
1631
+ * there, and `window.addEventListener(...)` throws "undefined is not a
1632
+ * function" (FZ-7798, SortableList).
1633
+ */
1634
+ const WINDOW_DOM_METHODS = [
1635
+ "addEventListener",
1636
+ "removeEventListener",
1637
+ "dispatchEvent",
1638
+ "matchMedia",
1639
+ "getComputedStyle",
1640
+ ];
1641
+
1642
+ /**
1643
+ * A call, `window.addEventListener(`, or any member of `document`, which is a
1644
+ * ReferenceError on its own where there is none. Not `x.document`, and not an
1645
+ * optional call, `window.matchMedia?.(...)`.
1646
+ */
1647
+ const NATIVE_DOM_CALL_RE = new RegExp(
1648
+ String.raw`(?<![\w$.])(?:(window)\s*\??\.\s*(${WINDOW_DOM_METHODS.join("|")})\b(?=\s*\()|(document)\s*\??\.\s*([A-Za-z_$][\w$]*))`,
1649
+ "g",
1650
+ );
1651
+
1652
+ /**
1653
+ * A guard that holds on native: a function-type check of a window member,
1654
+ * `typeof window.addEventListener === "function"`, a truthiness test of one,
1655
+ * `!window.matchMedia`, a check that a document exists,
1656
+ * `typeof document === "undefined"` (React Native has none), or a web-only
1657
+ * branch, `isWeb` / `Platform.OS === "web"`, which is false on every native
1658
+ * build. `typeof window` alone is the bug, so it does not count.
1659
+ */
1660
+ const NATIVE_DOM_GUARD_RE = new RegExp(
1661
+ [
1662
+ String.raw`\btypeof\s+(?:\(\s*)?(?:globalThis\s*\??\.\s*)?(?:window\s*\??\.\s*[A-Za-z_$]|document\b)`,
1663
+ String.raw`(?<![\w$.])window\s*\??\.\s*(?:${WINDOW_DOM_METHODS.join("|")})\b(?!\s*(?:\?\.\s*)?\()`,
1664
+ String.raw`\bisWeb\b`,
1665
+ String.raw`\bPlatform\.OS\s*[!=]==?\s*["']web["']`,
1666
+ ].join("|"),
1667
+ );
1668
+
1669
+ /**
1670
+ * True when `filePath` has a native twin beside it (`index.native.tsx` next to
1671
+ * `index.tsx`, or both `.ios` and `.android`), so Metro never loads it on a
1672
+ * phone and only the web resolves it.
1673
+ * @param {string} filePath
1674
+ */
1675
+ function hasNativeTwin(filePath) {
1676
+ const match = /^(.*)\.(tsx|ts|jsx|js)$/.exec(filePath);
1677
+ if (!match || /\.(native|ios|android)$/.test(match[1])) return false;
1678
+ const twin = (/** @type {string} */ platform) =>
1679
+ ["tsx", "ts", "jsx", "js"].some((ext) => existsSync(`${match[1]}.${platform}.${ext}`));
1680
+ return twin("native") || (twin("ios") && twin("android"));
1681
+ }
1682
+
1683
+ /**
1684
+ * The code before `index` in every block that encloses it, up to the top of
1685
+ * the file, with each block that opened and closed in between dropped. A guard
1686
+ * at the top of an effect covers the cleanup it returns; a guard inside a
1687
+ * sibling function does not.
1688
+ * @param {string} src
1689
+ * @param {Uint8Array} mask
1690
+ * @param {number} index
1691
+ */
1692
+ function enclosingBefore(src, mask, index) {
1693
+ let depth = 0;
1694
+ let kept = "";
1695
+ for (let at = index - 1; at >= 0; at--) {
1696
+ const char = src[at];
1697
+ if (mask[at] === 1 && char === "}") {
1698
+ depth++;
1699
+ continue;
1700
+ }
1701
+ if (mask[at] === 1 && char === "{") {
1702
+ if (depth > 0) depth--;
1703
+ continue;
1704
+ }
1705
+ if (depth === 0) kept = char + kept;
1706
+ }
1707
+ return kept;
1708
+ }
1709
+
1710
+ /**
1711
+ * Functions this file declares whose own body is a native guard, such as
1712
+ * `function canPickFile() { return typeof document !== "undefined"; }`, so a
1713
+ * call to one guards like the check it wraps. Only the same file counts.
1714
+ * @param {string} src
1715
+ * @param {Uint8Array} mask
1716
+ */
1717
+ function guardHelpers(src, mask) {
1718
+ /** @type {string[]} */
1719
+ const names = [];
1720
+ const declaration =
1721
+ /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(|\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*(?::\s*[\w$<>[\]|\s]+)?=>/g;
1722
+ for (const match of src.matchAll(declaration)) {
1723
+ const start = match.index ?? 0;
1724
+ if (mask[start] !== 1) continue;
1725
+ const name = match[1] ?? match[2];
1726
+ let at = start + match[0].length;
1727
+ let depth = match[1] ? 1 : 0;
1728
+ let end = src.length;
1729
+ for (; at < src.length; at++) {
1730
+ if (mask[at] !== 1) continue;
1731
+ const char = src[at];
1732
+ if (char === "(" || char === "{") depth++;
1733
+ else if (char === ")" || char === "}") {
1734
+ depth--;
1735
+ if (depth < 0) {
1736
+ end = at;
1737
+ break;
1738
+ }
1739
+ if (depth === 0 && char === "}") {
1740
+ end = at + 1;
1741
+ break;
1742
+ }
1743
+ } else if (depth === 0 && (char === ";" || char === "\n")) {
1744
+ end = at;
1745
+ break;
1746
+ }
1747
+ }
1748
+ if (NATIVE_DOM_GUARD_RE.test(src.slice(start, end))) names.push(name);
1749
+ }
1750
+ return names;
1751
+ }
1752
+
1753
+ /**
1754
+ * True when `name` is bound in this file other than as the global: an import,
1755
+ * a declaration, a typed parameter, `(document: GraphDoc) =>`, or a
1756
+ * destructured local, `const { document } = query` or
1757
+ * `const [document, setDocument] = useState(...)`. Passing the global along,
1758
+ * `f(window)`, is not a binding.
1759
+ * @param {string} src
1760
+ * @param {Uint8Array} mask
1761
+ * @param {string} name
1762
+ */
1763
+ function bindsNameOrParam(src, mask, name) {
1764
+ if (bindsName(src, mask, name)) return true;
1765
+ const param = new RegExp(
1766
+ String.raw`[(,]\s*${name}\s*\??:\s*[A-Z{]|\b(?:const|let|var)\s*[[{][^\]}]*\b${name}\b[^\]}]*[\]}]\s*=`,
1767
+ "g",
1768
+ );
1769
+ for (const match of src.matchAll(param)) {
1770
+ if (mask[match.index ?? 0] === 1) return true;
1771
+ }
1772
+ return false;
1773
+ }
1774
+
1775
+ /**
1776
+ * `window.addEventListener(...)`, `window.matchMedia(...)` or any
1777
+ * `document.<member>` in a public package's runtime source, with no native
1778
+ * guard earlier in an enclosing block (FZ-7799).
1779
+ *
1780
+ * React Native defines `window` as the global but leaves out the DOM, and has
1781
+ * no `document` at all. FZ-7798 found SortableList guarding with
1782
+ * `typeof window === "undefined"`, which passes on native, so mounting it threw
1783
+ * on window.addEventListener. Guard in place with
1784
+ * `typeof window.addEventListener === "function"` or
1785
+ * `typeof document !== "undefined"` (or call a same-file helper whose body is
1786
+ * that check), call it optionally (`window.addEventListener?.(...)`), or move
1787
+ * it into a .web twin.
1788
+ *
1789
+ * Exempt like the instanceof check: specs and stories, .web/.webext/.tauri
1790
+ * files, Node-only tooling and public/test-utils; and a file that binds the
1791
+ * name itself.
1792
+ */
1793
+ function checkNativeDomCalls() {
1794
+ /** @type {string[]} */
1795
+ const violations = [];
1796
+ if (!existsSync(PUBLIC_DIR)) return violations;
1797
+ for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
1798
+ if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
1799
+ if (WEB_ONLY_EXEMPT_PACKAGES.has(pkg.name)) continue;
1800
+ walkFiles(join(PUBLIC_DIR, pkg.name, "src"), (filePath) => {
1801
+ if (!WEB_ONLY_SCANNED_RE.test(filePath) || filePath.endsWith(".d.ts")) return;
1802
+ if (/\.(spec|test|stories|story)\./.test(filePath) || WEB_ONLY_FILE_RE.test(filePath)) return;
1803
+ const src = readFileSync(filePath, "utf8");
1804
+ if (!src.includes("window") && !src.includes("document")) return;
1805
+ if (hasNativeTwin(filePath)) return;
1806
+ if (NODE_BUILTIN_IMPORT_RE.test(src)) return;
1807
+ const mask = codeOffsets(src);
1808
+ const helpers = guardHelpers(src, mask);
1809
+ const helperCall = helpers.length
1810
+ ? new RegExp(String.raw`(?<!function\s+)\b(?:${helpers.join("|")})\s*\(`)
1811
+ : null;
1812
+ for (const match of src.matchAll(NATIVE_DOM_CALL_RE)) {
1813
+ const index = match.index ?? 0;
1814
+ if (mask[index] !== 1) continue;
1815
+ const name = match[1] ?? match[3];
1816
+ const member = match[2] ?? match[4];
1817
+ if (
1818
+ name === "window" &&
1819
+ /\btypeof\s*\(?\s*$/.test(src.slice(Math.max(0, index - 12), index))
1820
+ ) {
1821
+ continue;
1822
+ }
1823
+ if (bindsNameOrParam(src, mask, name)) continue;
1824
+ const before = enclosingBefore(src, mask, index);
1825
+ if (NATIVE_DOM_GUARD_RE.test(before) || helperCall?.test(before)) continue;
1826
+ violations.push(`${relative(ROOT, filePath)}:${lineAt(src, index)} ${name}.${member}`);
1827
+ }
1828
+ });
1829
+ }
1830
+ return violations;
1831
+ }
1832
+
1408
1833
  // ── React Flow selectors outside the token bridge (MPO-191) ───────────────
1409
1834
 
1410
1835
  const REACT_FLOW_SELECTOR_RE = /\.react-flow__[\w-]+/g;
@@ -2153,6 +2578,8 @@ export function runConventionChecks(options = {}) {
2153
2578
  const metroFallbacks = checkMetroSubpathFallbacks();
2154
2579
  const rnWebExports = checkReactNativeWebExports();
2155
2580
  const importMeta = checkImportMetaInPublicSource();
2581
+ const webOnlyInstanceof = checkWebOnlyInstanceof();
2582
+ const nativeDomCalls = checkNativeDomCalls();
2156
2583
  const graphVars = checkGraphVarsCoverage();
2157
2584
  const retiredScaleKnobs = checkRetiredScaleKnobs();
2158
2585
  const overlayOwnership = checkOverlayOwnership();
@@ -2359,6 +2786,24 @@ export function runConventionChecks(options = {}) {
2359
2786
  console.error("");
2360
2787
  }
2361
2788
 
2789
+ if (webOnlyInstanceof.length) {
2790
+ failed = true;
2791
+ console.error(
2792
+ 'Convention (FZ-7791 NO BARE instanceof OF A WEB-ONLY GLOBAL IN public/*/src): `v instanceof ReadableStream` names the global, and a runtime without it (Hermes has no ReadableStream; GJS and bare Node lack other DOM and WHATWG classes) throws a ReferenceError before the code it guards runs. Guard it earlier in the same block, `typeof ReadableStream !== "undefined" && v instanceof ReadableStream`, or move it into a .web twin. Specs, stories, .web/.webext/.tauri files and Node-only tooling (a value import of a `node:` builtin) are exempt.\n',
2793
+ );
2794
+ for (const msg of webOnlyInstanceof) console.error(` ${msg}`);
2795
+ console.error("");
2796
+ }
2797
+
2798
+ if (nativeDomCalls.length) {
2799
+ failed = true;
2800
+ console.error(
2801
+ 'Convention (FZ-7799 NO UNGUARDED window/document DOM CALL IN public/*/src): React Native makes `window` the global but gives it no addEventListener, removeEventListener, dispatchEvent, matchMedia or getComputedStyle, and has no `document`, so `typeof window === "undefined"` is not a native guard. Guard earlier in an enclosing block with `typeof window.addEventListener === "function"` or `typeof document !== "undefined"`, call it optionally (`window.addEventListener?.(...)`), or move it into a .web twin. Specs, stories, .web/.webext/.tauri files and Node-only tooling are exempt.\n',
2802
+ );
2803
+ for (const msg of nativeDomCalls) console.error(` ${msg}`);
2804
+ console.error("");
2805
+ }
2806
+
2362
2807
  if (tokens.violations.length) {
2363
2808
  failed = true;
2364
2809
  console.error(
package/oxlint.d.ts CHANGED
@@ -4,6 +4,7 @@ export const CHROME_PROPS: readonly string[];
4
4
  export const LAYOUT_PROPS: readonly string[];
5
5
  export const GEOMETRY_PROPS: readonly string[];
6
6
  export const MARGIN_PROPS: readonly string[];
7
+ export const RAW_TEXT_NAMES: readonly string[];
7
8
  export function isPaletteModule(filename: string | undefined): boolean;
8
9
  export function matchColorLiteral(value: unknown): string | undefined;
9
10
  export function findHexEscapes(src: string): { line: number; reason: string }[];
package/oxlint.mjs CHANGED
@@ -17,6 +17,12 @@
17
17
  * Tamagui*-prefixed typography escape hatches (TamaguiText, TamaguiHeading,
18
18
  * TamaguiH1…, TamaguiParagraph, TamaguiSizableText, TamaguiAnchor) outside
19
19
  * public/. House Text/Heading shadows are the bare names (MPO-20).
20
+ * mpo-conventions/no-voiceless-text — inside public/, raw tamagui typography
21
+ * (an import from tamagui, a Tamagui* hatch, or a styled() copy of one)
22
+ * renders only with a knob voice: a spread fragment or an explicit
23
+ * fontFamily, or nested in a text parent it inherits from. On native a
24
+ * bare text node takes no family, so bodyFont never reaches it (LC-137,
25
+ * FZ-7739). Everything else rides the house Text.
20
26
  * mpo-conventions/no-chrome-props — ban chrome (background, radius, type,
21
27
  * shadow, elevation) as props on imported @multiplatform.one/* components,
22
28
  * even at a valid token. Layout props (flex, alignSelf, sibling margins)
@@ -89,6 +95,26 @@ function sourceText(context) {
89
95
  const RAW_TYPOGRAPHY_HATCH = /^Tamagui(?:Text|SizableText|Paragraph|Heading|H[1-6]|Anchor)$/;
90
96
  const HOUSE_PACKAGE = /^@multiplatform\.one\//;
91
97
 
98
+ /** Tamagui's own text primitives, by the name their packages export. */
99
+ export const RAW_TEXT_NAMES = Object.freeze([
100
+ "Text",
101
+ "SizableText",
102
+ "Paragraph",
103
+ "Anchor",
104
+ "Heading",
105
+ "H1",
106
+ "H2",
107
+ "H3",
108
+ "H4",
109
+ "H5",
110
+ "H6",
111
+ "Label",
112
+ ]);
113
+ const RAW_TEXT_NAME_SET = new Set(RAW_TEXT_NAMES);
114
+ const TAMAGUI_TEXT_SOURCE = /^(?:tamagui|@tamagui\/(?:core|web|text|label))$/;
115
+ /** A JSX element that holds text: a native text child inherits its family. */
116
+ const TEXT_ELEMENT = /(?:^|\.)(?:[A-Za-z]*Text|Paragraph|Anchor|Label|Heading|H[1-6])$/;
117
+
92
118
  /**
93
119
  * Chrome props banned on imported `@multiplatform.one/*` components
94
120
  * (including Tamagui shorthands). Layout composition through `{...props}`
@@ -515,6 +541,95 @@ export const plugin = {
515
541
  };
516
542
  },
517
543
  },
544
+ "no-voiceless-text": {
545
+ meta: {
546
+ type: "problem",
547
+ docs: {
548
+ description:
549
+ "LC-137 / FZ-7739: raw tamagui typography in public/ must carry its knob voice (a spread fragment or fontFamily) or sit inside a text parent; plain text rides the house Text, SizableText or Paragraph.",
550
+ },
551
+ schema: [],
552
+ messages: {
553
+ voiceless:
554
+ "{{name}} is raw tamagui typography with no knob voice. On native it renders the system face whatever bodyFont says (LC-137). Render it through the house Text, SizableText or Paragraph (@multiplatform.one/theme, re-exported by components), or spread its knob voice (knobProps.body, label or heading).",
555
+ },
556
+ },
557
+ create(context) {
558
+ const raw = new Set();
559
+ /** Per open JSXElement: does it hold text? */
560
+ const stack = [];
561
+ const collect = (statement) => {
562
+ const node =
563
+ statement?.type === "ExportNamedDeclaration" ? statement.declaration : statement;
564
+ if (node?.type === "ImportDeclaration") {
565
+ if (node.importKind === "type") return false;
566
+ const fromTamagui = TAMAGUI_TEXT_SOURCE.test(node.source?.value ?? "");
567
+ let added = false;
568
+ for (const specifier of node.specifiers ?? []) {
569
+ if (specifier.type !== "ImportSpecifier" || specifier.importKind === "type") continue;
570
+ const imported = specifier.imported?.name ?? specifier.imported?.value;
571
+ const local = specifier.local?.name;
572
+ if (typeof imported !== "string" || !local || raw.has(local)) continue;
573
+ if (
574
+ (fromTamagui && RAW_TEXT_NAME_SET.has(imported)) ||
575
+ RAW_TYPOGRAPHY_HATCH.test(imported)
576
+ ) {
577
+ raw.add(local);
578
+ added = true;
579
+ }
580
+ }
581
+ return added;
582
+ }
583
+ if (node?.type !== "VariableDeclaration") return false;
584
+ let added = false;
585
+ for (const declarator of node.declarations ?? []) {
586
+ const init = declarator.init;
587
+ const local = declarator.id?.type === "Identifier" ? declarator.id.name : undefined;
588
+ if (!local || raw.has(local) || init?.type !== "CallExpression") continue;
589
+ if (init.callee?.type !== "Identifier" || init.callee.name !== "styled") continue;
590
+ const base = init.arguments?.[0];
591
+ if (base?.type !== "Identifier" || !raw.has(base.name)) continue;
592
+ const config = init.arguments?.[1];
593
+ const namesFamily =
594
+ config?.type === "ObjectExpression" &&
595
+ (config.properties ?? []).some(
596
+ (prop) =>
597
+ prop.type === "Property" && (prop.key?.name ?? prop.key?.value) === "fontFamily",
598
+ );
599
+ if (!namesFamily) {
600
+ raw.add(local);
601
+ added = true;
602
+ }
603
+ }
604
+ return added;
605
+ };
606
+ return {
607
+ Program(node) {
608
+ // styled() copies can chain and sit below their first use, so the
609
+ // module scope settles before any JSX is judged.
610
+ let grew = true;
611
+ while (grew) grew = (node.body ?? []).map(collect).some(Boolean);
612
+ },
613
+ JSXElement(node) {
614
+ const name = jsxName(node.openingElement?.name);
615
+ stack.push(raw.has(name) || TEXT_ELEMENT.test(name));
616
+ },
617
+ "JSXElement:exit"() {
618
+ stack.pop();
619
+ },
620
+ JSXOpeningElement(node) {
621
+ const name = jsxName(node.name);
622
+ if (!raw.has(name)) return;
623
+ if (stack.slice(0, -1).some(Boolean)) return;
624
+ const voiced = (node.attributes ?? []).some(
625
+ (attr) => attr.type === "JSXSpreadAttribute" || jsxAttrName(attr) === "fontFamily",
626
+ );
627
+ if (voiced) return;
628
+ context.report({ node, messageId: "voiceless", data: { name } });
629
+ },
630
+ };
631
+ },
632
+ },
518
633
  "no-chrome-props": {
519
634
  meta: {
520
635
  type: "problem",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/config",
3
- "version": "7.45.1",
3
+ "version": "7.47.0",
4
4
  "description": "Shared build and test configuration presets for multiplatform.one",
5
5
  "keywords": [
6
6
  "config",
@@ -106,7 +106,7 @@
106
106
  "access": "public"
107
107
  },
108
108
  "dependencies": {
109
- "@multiplatform.one/utils": "^7.45.1",
109
+ "@multiplatform.one/utils": "^7.47.0",
110
110
  "@tamagui/vite-plugin": "2.7.6",
111
111
  "@vitejs/plugin-react": "^6.1.2",
112
112
  "dotenv": "^17.4.2",
package/src/lint.spec.ts CHANGED
@@ -552,6 +552,419 @@ export type Where = PathLike;
552
552
  });
553
553
  });
554
554
 
555
+ describe("no bare instanceof of a web-only global in public/*/src (FZ-7791)", () => {
556
+ function run(root: string) {
557
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
558
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
559
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
560
+ const code = runConventionChecks({ roots: { root } });
561
+ const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
562
+ error.mockRestore();
563
+ log.mockRestore();
564
+ warn.mockRestore();
565
+ return { code, messages };
566
+ }
567
+
568
+ function publicSrc(root: string, pkg: string, name: string, body: string) {
569
+ mkdirSync(join(root, "public", pkg, "src"), { recursive: true });
570
+ writeFileSync(join(root, "public", pkg, "src", name), body);
571
+ return join("public", pkg, "src", name);
572
+ }
573
+
574
+ it("fails the HttpClient body check that threw on Hermes, naming the file and line", () => {
575
+ const root = makeTree();
576
+ const file = publicSrc(
577
+ root,
578
+ "frappe",
579
+ "http.ts",
580
+ `export function isBodyInit(body: unknown) {
581
+ return (
582
+ body instanceof ArrayBuffer ||
583
+ body instanceof ReadableStream ||
584
+ ArrayBuffer.isView(body)
585
+ );
586
+ }
587
+ `,
588
+ );
589
+ const { code, messages } = run(root);
590
+ expect(code).toBe(1);
591
+ expect(messages).toContain("NO BARE instanceof OF A WEB-ONLY GLOBAL");
592
+ expect(messages).toContain(`${file}:4 instanceof ReadableStream`);
593
+ expect(messages).not.toContain("instanceof ArrayBuffer");
594
+ });
595
+
596
+ it("fails DOM classes too, including the HTML*Element family", () => {
597
+ const root = makeTree();
598
+ const file = publicSrc(
599
+ root,
600
+ "forms",
601
+ "focus.ts",
602
+ "export const isInput = (node: unknown) => node instanceof HTMLInputElement;\n",
603
+ );
604
+ const { code, messages } = run(root);
605
+ expect(code).toBe(1);
606
+ expect(messages).toContain(`${file}:1 instanceof HTMLInputElement`);
607
+ });
608
+
609
+ it("passes a typeof guard in the same expression or an early return in the same block", () => {
610
+ const root = makeTree();
611
+ publicSrc(
612
+ root,
613
+ "frappe",
614
+ "guarded.ts",
615
+ `export function isStream(body: unknown) {
616
+ return typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
617
+ }
618
+ export function isElement(target: unknown) {
619
+ if (typeof Element === "undefined") return false;
620
+ if (!(target instanceof Element)) return false;
621
+ return target.closest("a") !== null;
622
+ }
623
+ `,
624
+ );
625
+ expect(run(root).code).toBe(0);
626
+ });
627
+
628
+ it("does not let a guard in another function cover this one", () => {
629
+ const root = makeTree();
630
+ const file = publicSrc(
631
+ root,
632
+ "table",
633
+ "rowPress.ts",
634
+ `export function hasDom() {
635
+ return typeof Element !== "undefined";
636
+ }
637
+ export function inRow(next: unknown) {
638
+ return next instanceof Element;
639
+ }
640
+ `,
641
+ );
642
+ const { code, messages } = run(root);
643
+ expect(code).toBe(1);
644
+ expect(messages).toContain(`${file}:5 instanceof Element`);
645
+ });
646
+
647
+ it("does not let a guard inside a closed sibling block cover the code after it", () => {
648
+ const root = makeTree();
649
+ const file = publicSrc(
650
+ root,
651
+ "components",
652
+ "useFocus.ts",
653
+ `export function useFocus(node: unknown, onMount: (cb: () => void) => void) {
654
+ onMount(() => {
655
+ if (typeof HTMLElement === "undefined") return;
656
+ });
657
+ return node instanceof HTMLElement ? node : null;
658
+ }
659
+ export const isEl = (v: unknown) => v instanceof Element;
660
+ export function hasElement() {
661
+ return typeof Element !== "undefined";
662
+ }
663
+ `,
664
+ );
665
+ const { code, messages } = run(root);
666
+ expect(code).toBe(1);
667
+ expect(messages).toContain(`${file}:5 instanceof HTMLElement`);
668
+ expect(messages).toContain(`${file}:7 instanceof Element`);
669
+ });
670
+
671
+ it("keeps reading code after a regex literal that holds a quote", () => {
672
+ const root = makeTree();
673
+ const file = publicSrc(
674
+ root,
675
+ "theme",
676
+ "audit.ts",
677
+ `export const unquote = (v: string) => v.replace(/^['"]|['"]$/g, "");
678
+ export const scoped = (scope: unknown) => (scope instanceof Element ? [scope] : []);
679
+ `,
680
+ );
681
+ const { code, messages } = run(root);
682
+ expect(code).toBe(1);
683
+ expect(messages).toContain(`${file}:2 instanceof Element`);
684
+ });
685
+
686
+ it("does not exempt a file whose comment only mentions an import of the name", () => {
687
+ const root = makeTree();
688
+ const file = publicSrc(
689
+ root,
690
+ "forms",
691
+ "picker.ts",
692
+ `// import the File from the picker and pass it from here
693
+ export const isFile = (v: unknown) => v instanceof File;
694
+ `,
695
+ );
696
+ const { code, messages } = run(root);
697
+ expect(code).toBe(1);
698
+ expect(messages).toContain(`${file}:2 instanceof File`);
699
+ });
700
+
701
+ it("exempts comments and strings, web twins, specs, Node tooling and a locally bound name", () => {
702
+ const root = makeTree();
703
+ publicSrc(
704
+ root,
705
+ "frappe",
706
+ "doc.ts",
707
+ `// a bare \`body instanceof ReadableStream\` threw on Hermes
708
+ export const why = "x instanceof Blob is banned";
709
+ `,
710
+ );
711
+ publicSrc(
712
+ root,
713
+ "components",
714
+ "focus.web.ts",
715
+ "export const f = (n: unknown) => n instanceof HTMLElement;\n",
716
+ );
717
+ publicSrc(
718
+ root,
719
+ "components",
720
+ "focus.spec.ts",
721
+ "export const f = (n: unknown) => n instanceof HTMLElement;\n",
722
+ );
723
+ publicSrc(
724
+ root,
725
+ "cli",
726
+ "upload.ts",
727
+ `import { readFileSync } from "node:fs";
728
+ export const isBlob = (v: unknown) => v instanceof Blob;
729
+ export const read = readFileSync;
730
+ `,
731
+ );
732
+ publicSrc(
733
+ root,
734
+ "rich-text",
735
+ "schema.ts",
736
+ `import { Node } from "prosemirror-model";
737
+ export const isDoc = (v: unknown) => v instanceof Node;
738
+ `,
739
+ );
740
+ expect(run(root).code).toBe(0);
741
+ });
742
+ });
743
+
744
+ describe("no unguarded window/document DOM call in public/*/src (FZ-7799)", () => {
745
+ function run(root: string) {
746
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
747
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
748
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
749
+ const code = runConventionChecks({ roots: { root } });
750
+ const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
751
+ error.mockRestore();
752
+ log.mockRestore();
753
+ warn.mockRestore();
754
+ return { code, messages };
755
+ }
756
+
757
+ function publicSrc(root: string, pkg: string, name: string, body: string) {
758
+ const dir = join(root, "public", pkg, "src", ...name.split("/").slice(0, -1));
759
+ mkdirSync(dir, { recursive: true });
760
+ writeFileSync(join(root, "public", pkg, "src", name), body);
761
+ return join("public", pkg, "src", name);
762
+ }
763
+
764
+ it("fails the SortableList keydown listener that a typeof window guard let through on React Native", () => {
765
+ const root = makeTree();
766
+ const file = publicSrc(
767
+ root,
768
+ "components",
769
+ "useSortableRows.ts",
770
+ `export function listen(enabled: boolean, onKey: (e: KeyboardEvent) => void) {
771
+ if (!enabled || typeof window === "undefined") return;
772
+ window.addEventListener("keydown", onKey);
773
+ return () => window.removeEventListener("keydown", onKey);
774
+ }
775
+ `,
776
+ );
777
+ const { code, messages } = run(root);
778
+ expect(code).toBe(1);
779
+ expect(messages).toContain("NO UNGUARDED window/document DOM CALL");
780
+ expect(messages).toContain(`${file}:3 window.addEventListener`);
781
+ expect(messages).toContain(`${file}:4 window.removeEventListener`);
782
+ });
783
+
784
+ it("fails window.matchMedia, window.dispatchEvent and any document member with no guard", () => {
785
+ const root = makeTree();
786
+ const file = publicSrc(
787
+ root,
788
+ "theme",
789
+ "dom.ts",
790
+ `export const dark = () => window.matchMedia("(prefers-color-scheme: dark)").matches;
791
+ export const ping = () => window.dispatchEvent(new Event("ping"));
792
+ export const lock = () => {
793
+ document.body.style.overflow = "hidden";
794
+ };
795
+ `,
796
+ );
797
+ const { code, messages } = run(root);
798
+ expect(code).toBe(1);
799
+ expect(messages).toContain(`${file}:1 window.matchMedia`);
800
+ expect(messages).toContain(`${file}:2 window.dispatchEvent`);
801
+ expect(messages).toContain(`${file}:4 document.body`);
802
+ });
803
+
804
+ it("fails typeof document.addEventListener on its own, which reads a document native lacks", () => {
805
+ const root = makeTree();
806
+ const file = publicSrc(
807
+ root,
808
+ "backoffice",
809
+ "shortcuts.ts",
810
+ `export function bind(onKey: (e: KeyboardEvent) => void) {
811
+ if (typeof document.addEventListener !== "function") return;
812
+ document.addEventListener("keydown", onKey);
813
+ }
814
+ `,
815
+ );
816
+ const { code, messages } = run(root);
817
+ expect(code).toBe(1);
818
+ expect(messages).toContain(`${file}:2 document.addEventListener`);
819
+ expect(messages).not.toContain(`${file}:3`);
820
+ });
821
+
822
+ it("passes a function-type check, a document check, an optional call, a truthiness test and a web-only branch", () => {
823
+ const root = makeTree();
824
+ publicSrc(
825
+ root,
826
+ "components",
827
+ "guarded.ts",
828
+ `import { Platform } from "react-native";
829
+ import { isWeb } from "tamagui";
830
+ export function listen(onKey: (e: KeyboardEvent) => void) {
831
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
832
+ window.addEventListener("keydown", onKey);
833
+ return () => {
834
+ window.removeEventListener("keydown", onKey);
835
+ };
836
+ }
837
+ export function lock() {
838
+ if (typeof document === "undefined") return;
839
+ document.body.style.overflow = "hidden";
840
+ }
841
+ export const reduced = () => !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
842
+ export function dark() {
843
+ if (typeof window === "undefined" || !window.matchMedia) return false;
844
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
845
+ }
846
+ export function webOnly(onKey: (e: KeyboardEvent) => void) {
847
+ if (!isWeb) return;
848
+ window.addEventListener("keydown", onKey);
849
+ }
850
+ export function platformOnly() {
851
+ if (Platform.OS !== "web") return null;
852
+ return document.activeElement;
853
+ }
854
+ `,
855
+ );
856
+ expect(run(root).code).toBe(0);
857
+ });
858
+
859
+ it("passes a call to a same-file helper whose body is the guard", () => {
860
+ const root = makeTree();
861
+ publicSrc(
862
+ root,
863
+ "backoffice",
864
+ "picker.ts",
865
+ `export function canPickFile(): boolean {
866
+ return typeof document !== "undefined" && typeof document.createElement === "function";
867
+ }
868
+ export function pickFile() {
869
+ if (!canPickFile()) return undefined;
870
+ return document.createElement("input");
871
+ }
872
+ `,
873
+ );
874
+ expect(run(root).code).toBe(0);
875
+ });
876
+
877
+ it("does not let a guard in a sibling function cover this one", () => {
878
+ const root = makeTree();
879
+ const file = publicSrc(
880
+ root,
881
+ "forms",
882
+ "escape.ts",
883
+ `export function hasDom() {
884
+ return typeof document !== "undefined";
885
+ }
886
+ export function useEscape(onKey: (e: KeyboardEvent) => void) {
887
+ document.addEventListener("keydown", onKey);
888
+ }
889
+ `,
890
+ );
891
+ const { code, messages } = run(root);
892
+ expect(code).toBe(1);
893
+ expect(messages).toContain(`${file}:5 document.addEventListener`);
894
+ });
895
+
896
+ it("exempts web twins, a file with a native twin, specs, comments, strings and a local document", () => {
897
+ const root = makeTree();
898
+ publicSrc(
899
+ root,
900
+ "components",
901
+ "keys.web.ts",
902
+ 'export const on = (f: () => void) => window.addEventListener("keydown", f);\n',
903
+ );
904
+ publicSrc(
905
+ root,
906
+ "components",
907
+ "SheetModal/index.tsx",
908
+ 'export const on = (f: () => void) => window.addEventListener("keydown", f);\n',
909
+ );
910
+ publicSrc(root, "components", "SheetModal/index.native.tsx", "export const on = () => {};\n");
911
+ publicSrc(
912
+ root,
913
+ "components",
914
+ "keys.spec.ts",
915
+ 'export const on = (f: () => void) => window.addEventListener("keydown", f);\n',
916
+ );
917
+ publicSrc(
918
+ root,
919
+ "frappe",
920
+ "doc.ts",
921
+ `// window.addEventListener threw on Hermes
922
+ export const why = "document.body is missing on native";
923
+ `,
924
+ );
925
+ publicSrc(
926
+ root,
927
+ "node-graph",
928
+ "legal.ts",
929
+ `export function ids(query: { document: { nodes: { id: string }[] } }) {
930
+ const { document } = query;
931
+ return document.nodes.map((node) => node.id);
932
+ }
933
+ export const count = (document: { nodes: unknown[] }) => document.nodes.length;
934
+ `,
935
+ );
936
+ publicSrc(
937
+ root,
938
+ "node-graph",
939
+ "useDoc.ts",
940
+ `import { useState } from "react";
941
+ export function useDoc() {
942
+ const [document, setDocument] = useState<{ nodes: unknown[] } | null>(null);
943
+ return { size: document?.nodes.length ?? 0, setDocument };
944
+ }
945
+ `,
946
+ );
947
+ expect(run(root).code).toBe(0);
948
+ });
949
+
950
+ it("does not treat passing the global along as a local binding", () => {
951
+ const root = makeTree();
952
+ const file = publicSrc(
953
+ root,
954
+ "web3",
955
+ "inject.ts",
956
+ `export function inject(register: (w: unknown) => void, onReady: () => void) {
957
+ register(window);
958
+ window.addEventListener("ready", onReady);
959
+ }
960
+ `,
961
+ );
962
+ const { code, messages } = run(root);
963
+ expect(code).toBe(1);
964
+ expect(messages).toContain(`${file}:3 window.addEventListener`);
965
+ });
966
+ });
967
+
555
968
  describe("a scaffolded project, where apps/one is renamed and public/ is pruned (MPO-318)", () => {
556
969
  function run(root: string) {
557
970
  const error = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -0,0 +1,151 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { afterAll, describe, expect, it } from "vitest";
8
+
9
+ // FZ-7739 / LC-137: on native a raw tamagui text node takes no family and
10
+ // inherits none, so bodyFont never reaches it. These run the real oxlint
11
+ // binary over real TSX, so the AST shapes are oxlint's own.
12
+ const packageDir = join(dirname(fileURLToPath(import.meta.url)), "..");
13
+ const oxlintBin = join(
14
+ dirname(createRequire(import.meta.url).resolve("oxlint/package.json")),
15
+ "bin",
16
+ "oxlint",
17
+ );
18
+
19
+ const project = mkdtempSync(join(tmpdir(), "mpo-voiceless-text-"));
20
+ afterAll(() => rmSync(project, { recursive: true, force: true }));
21
+
22
+ function lint(source: string): string[] {
23
+ mkdirSync(join(project, "node_modules", "@multiplatform.one"), { recursive: true });
24
+ try {
25
+ symlinkSync(packageDir, join(project, "node_modules", "@multiplatform.one", "config"));
26
+ } catch {
27
+ // already linked by an earlier case
28
+ }
29
+ writeFileSync(
30
+ join(project, ".oxlintrc.json"),
31
+ JSON.stringify({
32
+ jsPlugins: ["@multiplatform.one/config/oxlint"],
33
+ rules: { "mpo-conventions/no-voiceless-text": "error" },
34
+ }),
35
+ );
36
+ const file = "public/demo/src/Demo.tsx";
37
+ mkdirSync(dirname(join(project, file)), { recursive: true });
38
+ writeFileSync(join(project, file), source);
39
+ let out: string;
40
+ try {
41
+ out = execFileSync(oxlintBin, ["-c", ".oxlintrc.json", "-f", "unix", file], {
42
+ cwd: project,
43
+ encoding: "utf8",
44
+ });
45
+ } catch (error) {
46
+ out = String((error as { stdout?: string }).stdout ?? "");
47
+ }
48
+ return out
49
+ .split("\n")
50
+ .filter((line) => line.includes("no-voiceless-text"))
51
+ .map((line) => /^[^:]+:(\d+):/.exec(line)?.[1] ?? line);
52
+ }
53
+
54
+ describe("mpo-conventions/no-voiceless-text (FZ-7739, LC-137)", () => {
55
+ it("reports raw text with no voice and stays silent on a spread voice or a family", () => {
56
+ const lines = lint(
57
+ [
58
+ 'import { useResolvedKnobs } from "@multiplatform.one/theme";',
59
+ 'import { Paragraph, SizableText, Text } from "tamagui";',
60
+ "export function Demo() {",
61
+ " const { knobProps } = useResolvedKnobs();",
62
+ " return (",
63
+ " <>",
64
+ " <Text>dead</Text>",
65
+ ' <SizableText size="$2">dead</SizableText>',
66
+ " <Paragraph>dead</Paragraph>",
67
+ " <Text {...knobProps.body}>voiced</Text>",
68
+ ' <Text fontFamily="$mono">code</Text>',
69
+ " </>",
70
+ " );",
71
+ "}",
72
+ "",
73
+ ].join("\n"),
74
+ );
75
+ expect(lines).toEqual(["7", "8", "9"]);
76
+ });
77
+
78
+ it("lets a text nested in another text inherit, and still reports one inside a view", () => {
79
+ const lines = lint(
80
+ [
81
+ 'import { Text } from "@multiplatform.one/theme";',
82
+ 'import { Text as TamaguiText, View } from "tamagui";',
83
+ "export function Demo() {",
84
+ " return (",
85
+ " <View>",
86
+ " <Text>",
87
+ ' house <TamaguiText fontWeight="700">bold</TamaguiText>',
88
+ " </Text>",
89
+ " <TamaguiText>dead</TamaguiText>",
90
+ " </View>",
91
+ " );",
92
+ "}",
93
+ "",
94
+ ].join("\n"),
95
+ );
96
+ expect(lines).toEqual(["9"]);
97
+ });
98
+
99
+ it("follows styled() copies, even ones declared below their use", () => {
100
+ const lines = lint(
101
+ [
102
+ 'import { styled, Text } from "tamagui";',
103
+ "export function Demo() {",
104
+ " return (",
105
+ " <>",
106
+ " <Caption>dead</Caption>",
107
+ " <Mono>fixed family</Mono>",
108
+ " </>",
109
+ " );",
110
+ "}",
111
+ 'const Caption = styled(Text, { color: "$color11" });',
112
+ 'const Mono = styled(Text, { fontFamily: "$mono" });',
113
+ "",
114
+ ].join("\n"),
115
+ );
116
+ expect(lines).toEqual(["5"]);
117
+ });
118
+
119
+ it("covers the Tamagui* hatches and stays silent on house names and other packages", () => {
120
+ const lines = lint(
121
+ [
122
+ 'import { TamaguiParagraph, Text } from "@multiplatform.one/components";',
123
+ 'import { Text as Other } from "some-other-package";',
124
+ "export function Demo() {",
125
+ " return (",
126
+ " <>",
127
+ " <TamaguiParagraph>dead</TamaguiParagraph>",
128
+ " <Text>house</Text>",
129
+ " <Other>other</Other>",
130
+ " </>",
131
+ " );",
132
+ "}",
133
+ "",
134
+ ].join("\n"),
135
+ );
136
+ expect(lines).toEqual(["6"]);
137
+ });
138
+
139
+ it("ignores type-only imports", () => {
140
+ expect(
141
+ lint(
142
+ [
143
+ 'import type { TextProps } from "tamagui";',
144
+ 'import { Text } from "@multiplatform.one/theme";',
145
+ "export const Demo = (props: TextProps) => <Text {...props} />;",
146
+ "",
147
+ ].join("\n"),
148
+ ),
149
+ ).toEqual([]);
150
+ });
151
+ });