@colixsystems/widget-sdk 0.275.1 → 0.276.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/dist/linter.cjs CHANGED
@@ -528,15 +528,22 @@ const _ASSET_SPECIFIER_RE = /\.(css|png|jpe?g|gif)$/i;
528
528
  const _ICON_DEFAULT_CONFIGURED_RE =
529
529
  /\bIcon\s*\.\s*Default\s*(\.\s*mergeOptions\s*\(|\.\s*imagePath\s*=)/;
530
530
 
531
- // Line of an index in `code`, counted forward: indexes must not decrease.
531
+ // 1-based line of any index in `code`, by binary search over the newline
532
+ // offsets: counting from the top per finding is quadratic (sc-8204).
532
533
  function _lineCounter(code) {
533
- let line = 1;
534
- let at = 0;
534
+ const newlines = [];
535
+ for (let i = code.indexOf("\n"); i !== -1; i = code.indexOf("\n", i + 1)) {
536
+ newlines.push(i);
537
+ }
535
538
  return (index) => {
536
- for (; at < index; at += 1) {
537
- if (code.charCodeAt(at) === 10) line += 1;
539
+ let lo = 0;
540
+ let hi = newlines.length;
541
+ while (lo < hi) {
542
+ const mid = (lo + hi) >> 1;
543
+ if (newlines[mid] < index) lo = mid + 1;
544
+ else hi = mid;
538
545
  }
539
- return line;
546
+ return lo + 1;
540
547
  };
541
548
  }
542
549
 
@@ -584,6 +591,9 @@ function _leafletRules(source) {
584
591
  let tag;
585
592
  while ((tag = _MARKER_TAG_RE.exec(code))) {
586
593
  const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
594
+ // Resume past the tag even when it passes: rescanning a tag nested in
595
+ // its attributes made unclosed runs quadratic (sc-8204).
596
+ _MARKER_TAG_RE.lastIndex = end;
587
597
  if (/(^|\s)icon\s*=/.test(code.slice(tag.index, end))) continue;
588
598
  const line = markerLineAt(tag.index);
589
599
  findings.push({
@@ -601,7 +611,6 @@ function _leafletRules(source) {
601
611
  line,
602
612
  snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
603
613
  });
604
- _MARKER_TAG_RE.lastIndex = end;
605
614
  }
606
615
  return findings;
607
616
  }
@@ -1043,8 +1052,32 @@ const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
1043
1052
  // `[^}]*` (not `[\s\S]*?`) so the brace capture can never cross a `}` into a
1044
1053
  // neighbouring import — otherwise a preceding `import { View, Pressable } from
1045
1054
  // "react-native"` is swallowed and its names get validated as lucide icons.
1046
- const LUCIDE_IMPORT_RE =
1047
- /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([^}]*)\}\s*from\s*["']lucide-react-native["']/g;
1055
+ const LUCIDE_IMPORT_HEAD_RE = /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{/g;
1056
+ const LUCIDE_IMPORT_TAIL_RE = /\}\s*from\s*["']lucide-react-native["']/y;
1057
+
1058
+ // `import { … } from "lucide-react-native"` clauses. The names run to the
1059
+ // NEXT `}`, so every head sharing that brace shares one tail check; one
1060
+ // regex retried that run from each unclosed `import {` (sc-8204).
1061
+ function _lucideImports(source) {
1062
+ const found = [];
1063
+ const tails = new Map();
1064
+ LUCIDE_IMPORT_HEAD_RE.lastIndex = 0;
1065
+ let head;
1066
+ while ((head = LUCIDE_IMPORT_HEAD_RE.exec(source))) {
1067
+ const from = LUCIDE_IMPORT_HEAD_RE.lastIndex;
1068
+ const close = source.indexOf("}", from);
1069
+ if (close === -1) break;
1070
+ if (!tails.has(close)) {
1071
+ LUCIDE_IMPORT_TAIL_RE.lastIndex = close;
1072
+ tails.set(close, LUCIDE_IMPORT_TAIL_RE.test(source) ? LUCIDE_IMPORT_TAIL_RE.lastIndex : -1);
1073
+ }
1074
+ const end = tails.get(close);
1075
+ if (end === -1) continue;
1076
+ found.push({ index: head.index, names: source.slice(from, close) });
1077
+ LUCIDE_IMPORT_HEAD_RE.lastIndex = end;
1078
+ }
1079
+ return found;
1080
+ }
1048
1081
 
1049
1082
  // lucide re-exports each base icon as `<Name>`, `<Name>Icon`, and `Lucide<Name>`;
1050
1083
  // the committed set holds only the base names, so normalise the alias forms
@@ -1060,11 +1093,10 @@ function _isValidLucideName(name) {
1060
1093
  function _lucideIconRules(source) {
1061
1094
  const findings = [];
1062
1095
  const sourceLines = source.split(/\r?\n/);
1063
- LUCIDE_IMPORT_RE.lastIndex = 0;
1064
- let m;
1065
- while ((m = LUCIDE_IMPORT_RE.exec(source))) {
1066
- const line = source.slice(0, m.index).split(/\r?\n/).length;
1067
- for (const raw of (m[1] || "").split(",")) {
1096
+ const lineAt = _lineCounter(source);
1097
+ for (const m of _lucideImports(source)) {
1098
+ const line = lineAt(m.index);
1099
+ for (const raw of m.names.split(",")) {
1068
1100
  // Strip any inline comment from the specifier token so a `{ Home, // x`
1069
1101
  // comment can't glue onto and hide the next specifier (e.g. `House`).
1070
1102
  const cleaned = raw
@@ -1147,6 +1179,25 @@ const _EXPRESSION_LEAD_WORDS = new Set([
1147
1179
  "instanceof",
1148
1180
  ]);
1149
1181
 
1182
+ // The offset just past the whitespace run ending before `at`.
1183
+ function _skipSpaceBack(code, at) {
1184
+ while (at > 0 && /\s/.test(code[at - 1])) at--;
1185
+ return at;
1186
+ }
1187
+
1188
+ function _skipSpaceForward(code, at) {
1189
+ while (at < code.length && /\s/.test(code[at])) at++;
1190
+ return at;
1191
+ }
1192
+
1193
+ // The identifier ending at `last`; leading digits are not part of it.
1194
+ function _wordEndingAt(code, last) {
1195
+ let start = last + 1;
1196
+ while (start > 0 && /[A-Za-z0-9_$]/.test(code[start - 1])) start--;
1197
+ while (start <= last && /[0-9]/.test(code[start])) start++;
1198
+ return code.slice(start, last + 1);
1199
+ }
1200
+
1150
1201
  // 1-based line of the first unbound reference to `name`, or 0 when the name is
1151
1202
  // bound, never referenced, or referenced in a form this scan cannot decide.
1152
1203
  function _unboundReferenceLine(code, name) {
@@ -1156,22 +1207,24 @@ function _unboundReferenceLine(code, name) {
1156
1207
  const word = new RegExp(`\\b${name}\\b`, "g");
1157
1208
  let firstIndex = -1;
1158
1209
  let match;
1210
+ // Every look-around walks a few characters from the match: slicing and
1211
+ // regex-testing the whole prefix per match was quadratic (sc-8204).
1159
1212
  while ((match = word.exec(code))) {
1160
- const before = code.slice(0, match.index).replace(/\s+$/, "");
1213
+ const leadAt = _skipSpaceBack(code, match.index) - 1;
1214
+ const lead = leadAt >= 0 ? code[leadAt] : "";
1161
1215
  // `obj.NAME` / `obj?.NAME` / `{...NAME}` — never this binding.
1162
- if (before.endsWith(".")) continue;
1163
- const lead = before.slice(-1);
1216
+ if (lead === ".") continue;
1164
1217
  if (/[A-Za-z0-9_$]/.test(lead)) {
1165
- const leadWord = (before.match(/[A-Za-z_$][A-Za-z0-9_$]*$/) || [""])[0];
1166
- if (!_EXPRESSION_LEAD_WORDS.has(leadWord)) return 0;
1167
- } else if (lead === ">" && !before.endsWith("=>")) {
1218
+ if (!_EXPRESSION_LEAD_WORDS.has(_wordEndingAt(code, leadAt))) return 0;
1219
+ } else if (lead === ">" && code[leadAt - 1] !== "=") {
1168
1220
  // A `>` that is not an arrow closes a JSX tag, so what follows is text.
1169
1221
  return 0;
1170
1222
  }
1171
- const after = code.slice(match.index + name.length).replace(/^\s+/, "");
1172
- const isReference =
1173
- /<\s*\/?$/.test(before) || after.startsWith("(") || after.startsWith(".");
1174
- if (!isReference) return 0;
1223
+ const opensTag =
1224
+ lead === "<" ||
1225
+ (lead === "/" && code[_skipSpaceBack(code, leadAt) - 1] === "<");
1226
+ const next = code[_skipSpaceForward(code, match.index + name.length)];
1227
+ if (!opensTag && next !== "(" && next !== ".") return 0;
1175
1228
  if (firstIndex < 0) firstIndex = match.index;
1176
1229
  }
1177
1230
  if (firstIndex < 0) return 0;
@@ -1253,10 +1306,11 @@ function _bundledAssetRules(source, assetFiles) {
1253
1306
  // Comments blanked, strings kept: the file name IS a string literal.
1254
1307
  const code = _stripNonCode(source, { keepStrings: true });
1255
1308
  const sourceLines = source.split(/\r?\n/);
1309
+ const lineAt = _lineCounter(code);
1256
1310
  for (const match of code.matchAll(BUNDLED_ASSET_CALL_RE)) {
1257
1311
  const name = match[1] || match[2] || match[3] || "";
1258
1312
  if (name.includes("${") || assetFiles.includes(name)) continue;
1259
- const line = code.slice(0, match.index).split(/\r?\n/).length;
1313
+ const line = lineAt(match.index);
1260
1314
  findings.push({
1261
1315
  rule: "bundled-asset-missing",
1262
1316
  severity: "error",
@@ -1283,6 +1337,7 @@ function _paymentCurrencyRules(source) {
1283
1337
  // Comments blanked, strings kept: the currency code IS a string literal.
1284
1338
  const code = _stripNonCode(source, { keepStrings: true });
1285
1339
  const sourceLines = source.split(/\r?\n/);
1340
+ const lineAt = _lineCounter(code);
1286
1341
  let from = 0;
1287
1342
  for (;;) {
1288
1343
  const at = code.indexOf(REQUEST_PAYMENT_CALL, from);
@@ -1293,7 +1348,7 @@ function _paymentCurrencyRules(source) {
1293
1348
  );
1294
1349
  if (!match) continue;
1295
1350
  // Line of the currency literal itself, not of the call.
1296
- const line = code.slice(0, at + match.index).split(/\r?\n/).length;
1351
+ const line = lineAt(at + match.index);
1297
1352
  findings.push({
1298
1353
  rule: "payment-currency",
1299
1354
  severity: "error",
@@ -1472,22 +1527,36 @@ function _datastoreErrorHandlingRules(source) {
1472
1527
  // the deps array is wrong (sc-1579's volatile-query gate exists because of
1473
1528
  // exactly this mistake). Flags only when the file has NOT already reached
1474
1529
  // for `useStableQuery`, the hook that removes the deps array entirely.
1530
+ // The first `const NAME = useMemo(` of every name, in one pass: a regex
1531
+ // per queried name rescanned the source per call (sc-8204). A name holding
1532
+ // `$` never matched the old per-name pattern, so it is left out.
1533
+ function _firstUseMemoConsts(code) {
1534
+ const first = new Map();
1535
+ const re = /\bconst\s+([A-Za-z_][\w$]*)\s*=\s*useMemo\s*\(/g;
1536
+ let m;
1537
+ while ((m = re.exec(code))) {
1538
+ if (!m[1].includes("$") && !first.has(m[1])) first.set(m[1], m.index);
1539
+ }
1540
+ return first;
1541
+ }
1542
+
1475
1543
  function _rawUseMemoIntoDatastoreQueryRules(source) {
1476
1544
  const code = _stripNonCode(source);
1477
1545
  if (/\buseStableQuery\s*\(/.test(code)) return [];
1478
1546
  const callRe = /\buseDatastoreQuery\s*\(\s*[^,()]+,\s*([A-Za-z_$][\w$]*)\s*[,)]/g;
1479
1547
  const findings = [];
1480
1548
  const sourceLines = source.split(/\r?\n/);
1549
+ const lineAt = _lineCounter(code);
1550
+ const memoAt = _firstUseMemoConsts(code);
1481
1551
  const seen = new Set();
1482
1552
  let m;
1483
1553
  while ((m = callRe.exec(code))) {
1484
1554
  const varName = m[1];
1485
1555
  if (seen.has(varName)) continue;
1486
- const memoRe = new RegExp(`\\bconst\\s+${varName}\\s*=\\s*useMemo\\s*\\(`);
1487
- const memoMatch = memoRe.exec(code);
1488
- if (!memoMatch || memoMatch.index >= m.index) continue;
1556
+ const memoIndex = memoAt.get(varName);
1557
+ if (memoIndex === undefined || memoIndex >= m.index) continue;
1489
1558
  seen.add(varName);
1490
- const line = code.slice(0, memoMatch.index).split(/\r?\n/).length;
1559
+ const line = lineAt(memoIndex);
1491
1560
  findings.push({
1492
1561
  rule: "raw-useMemo-into-datastore-query",
1493
1562
  severity: "warning",
@@ -1560,30 +1629,81 @@ function _rowActionGatingRules(source) {
1560
1629
  // The useDatastoreMutation() call whose update or delete the widget uses,
1561
1630
  // either as `m.update(` / `m.delete(` or destructured `{ update, delete }`.
1562
1631
  function _rowMutationCall(code) {
1563
- const hookCall = /(?:\{([^}]*)\}|\b(\w+))\s*=\s*useDatastoreMutation\s*\(/g;
1564
- for (const match of code.matchAll(hookCall)) {
1565
- const [, destructured, name] = match;
1566
- const usesRowVerb = destructured
1567
- ? /\b(update|delete)\b/.test(destructured)
1568
- : new RegExp(`\\b${name}\\s*\\.\\s*(update|delete)\\s*\\(`).test(code);
1569
- if (usesRowVerb) return match;
1632
+ const rowVerbCallers = new Set();
1633
+ for (const m of code.matchAll(/\b(\w+)\s*\.\s*(?:update|delete)\s*\(/g)) {
1634
+ rowVerbCallers.add(m[1]);
1635
+ }
1636
+ for (const call of _mutationHookCalls(code)) {
1637
+ // An empty `{}` read as a name before: the regex built "undefined".
1638
+ const usesRowVerb = call.destructured
1639
+ ? /\b(update|delete)\b/.test(call.destructured)
1640
+ : rowVerbCallers.has(String(call.name));
1641
+ if (usesRowVerb) return call;
1570
1642
  }
1571
1643
  return null;
1572
1644
  }
1573
1645
 
1646
+ const _MUTATION_TAIL_RE = /\}\s*=\s*useDatastoreMutation\s*\(/y;
1647
+ const _NAMED_MUTATION_RE = /\b(\w+)\s*=\s*useDatastoreMutation\s*\(/g;
1648
+
1649
+ // Every `{ … } = useDatastoreMutation(` / `name = useDatastoreMutation(`, in
1650
+ // source order, as one `(?:\{([^}]*)\}|\b(\w+))…` regex would match them.
1651
+ // The destructured names run to the NEXT `}`, so each `}` is checked once;
1652
+ // the regex re-read that run from every unclosed `{` (sc-8204).
1653
+ function _mutationHookCalls(code) {
1654
+ const calls = [];
1655
+ const tails = new Map();
1656
+ let named = null;
1657
+ let brace = code.indexOf("{");
1658
+ let close = -1;
1659
+ for (let from = 0; ; ) {
1660
+ if (named === null || named.index < from) {
1661
+ _NAMED_MUTATION_RE.lastIndex = from;
1662
+ named = _NAMED_MUTATION_RE.exec(code) || { index: Infinity };
1663
+ }
1664
+ let destructured = null;
1665
+ for (; brace !== -1 && brace < named.index; brace = code.indexOf("{", brace + 1)) {
1666
+ if (brace < from) continue;
1667
+ if (close <= brace) close = code.indexOf("}", brace + 1);
1668
+ if (close === -1) {
1669
+ brace = -1;
1670
+ break;
1671
+ }
1672
+ if (!tails.has(close)) {
1673
+ _MUTATION_TAIL_RE.lastIndex = close;
1674
+ tails.set(close, _MUTATION_TAIL_RE.test(code) ? _MUTATION_TAIL_RE.lastIndex : -1);
1675
+ }
1676
+ if (tails.get(close) !== -1) {
1677
+ destructured = { index: brace, destructured: code.slice(brace + 1, close) };
1678
+ break;
1679
+ }
1680
+ }
1681
+ if (destructured) {
1682
+ calls.push(destructured);
1683
+ from = tails.get(close);
1684
+ } else if (named.index !== Infinity) {
1685
+ calls.push({ index: named.index, name: named[1] });
1686
+ from = named.index + named[0].length;
1687
+ } else return calls;
1688
+ }
1689
+ }
1690
+
1574
1691
  function _imagePercentHeightRules(source) {
1575
1692
  const findings = [];
1576
1693
  const code = _stripNonCode(source, { keepStrings: true });
1577
1694
  const sourceLines = source.split(/\r?\n/);
1695
+ const lineAt = _lineCounter(code);
1578
1696
  _IMAGE_TAG_RE.lastIndex = 0;
1579
1697
  let tag;
1580
1698
  while ((tag = _IMAGE_TAG_RE.exec(code))) {
1581
1699
  const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
1700
+ // A tag nested in these attributes is inside `attrs` already (sc-8204).
1701
+ _IMAGE_TAG_RE.lastIndex = end;
1582
1702
  const attrs = code.slice(tag.index, end);
1583
1703
  _PERCENT_HEIGHT_RE.lastIndex = 0;
1584
1704
  const hit = _PERCENT_HEIGHT_RE.exec(attrs);
1585
1705
  if (!hit) continue;
1586
- const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
1706
+ const line = lineAt(tag.index + hit.index);
1587
1707
  findings.push({
1588
1708
  rule: "image-percent-height",
1589
1709
  severity: "warning",
@@ -1598,7 +1718,6 @@ function _imagePercentHeightRules(source) {
1598
1718
  line,
1599
1719
  snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
1600
1720
  });
1601
- _IMAGE_TAG_RE.lastIndex = end;
1602
1721
  }
1603
1722
  return findings;
1604
1723
  }
@@ -1615,10 +1734,11 @@ function _flexBasisPercentRules(source) {
1615
1734
  const findings = [];
1616
1735
  const code = _stripNonCode(source, { keepStrings: true });
1617
1736
  const sourceLines = source.split(/\r?\n/);
1737
+ const lineAt = _lineCounter(code);
1618
1738
  _FLEX_BASIS_PERCENT_RE.lastIndex = 0;
1619
1739
  let hit;
1620
1740
  while ((hit = _FLEX_BASIS_PERCENT_RE.exec(code))) {
1621
- const line = code.slice(0, hit.index).split(/\r?\n/).length;
1741
+ const line = lineAt(hit.index);
1622
1742
  findings.push({
1623
1743
  rule: "flex-basis-percent",
1624
1744
  severity: "warning",
@@ -1651,42 +1771,45 @@ const _SIDE_MARGIN_KEY_RE =
1651
1771
  const _ZERO_VALUE_RE =
1652
1772
  /^\s*(?:(["'`]?)-?0(?:\.0*)?(?:px)?\1\s*(?:[,}]|$)|(?:undefined|null)\b)/m;
1653
1773
 
1654
- // The innermost `{ … }` around `at`, as [start, end); null outside any.
1655
- function _enclosingObject(code, at) {
1656
- let start = -1;
1657
- for (let i = at - 1, depth = 0; i >= 0; i--) {
1658
- if (code[i] === "}") depth++;
1659
- else if (code[i] === "{" && depth-- === 0) {
1660
- start = i;
1661
- break;
1774
+
1775
+ // The object's own keys only: a nested object's margin is not this box's.
1776
+ // Blanked rather than cut, so an offset here is the same offset in the source.
1777
+ // Partner offset of every brace matched by stack in `text`, else -1.
1778
+ function _bracePartners(text) {
1779
+ const partner = new Int32Array(text.length).fill(-1);
1780
+ const opens = [];
1781
+ for (let i = 0; i < text.length; i++) {
1782
+ if (text[i] === "{") opens.push(i);
1783
+ else if (text[i] === "}" && opens.length > 0) {
1784
+ const open = opens.pop();
1785
+ partner[open] = i;
1786
+ partner[i] = open;
1662
1787
  }
1663
1788
  }
1664
- if (start < 0) return null;
1665
- for (let j = at, depth = 0; j < code.length; j++) {
1666
- if (code[j] === "{") depth++;
1667
- else if (code[j] === "}" && depth-- === 0) return [start, j + 1];
1789
+ return partner;
1790
+ }
1791
+
1792
+ // An object's own level: each nested `{…}` collapsed to one space, with the
1793
+ // offset in `text` of every character kept. Walking only that level means
1794
+ // an ancestor never re-reads its descendants (sc-8204).
1795
+ function _ownKeys(text, partner, [start, end]) {
1796
+ let own = "";
1797
+ const at = [];
1798
+ for (let i = start; i < end; i++) {
1799
+ const close = partner[i];
1800
+ const nested = text[i] === "{" && i !== start && close > i && close < end - 1;
1801
+ own += nested ? " " : text[i];
1802
+ at.push(i);
1803
+ if (nested) i = close;
1668
1804
  }
1669
- return null;
1805
+ return { own, at };
1670
1806
  }
1671
1807
 
1672
- // The object's own keys only: a nested object's margin is not this box's.
1673
- // Blanked rather than cut, so an offset here is the same offset in the source.
1674
- function _ownKeysMask(structure, [start, end]) {
1675
- let body = structure.slice(start, end);
1676
- let previous;
1677
- do {
1678
- previous = body;
1679
- body = body.replace(/(?<!^)\{[^{}]*\}(?!$)/g, (nested) => " ".repeat(nested.length));
1680
- } while (body !== previous);
1681
- return body;
1682
- }
1683
-
1684
- function _hasSideMargin(code, structure, span) {
1685
- const keys = _ownKeysMask(structure, span);
1808
+ function _hasSideMargin(code, keys) {
1686
1809
  _SIDE_MARGIN_KEY_RE.lastIndex = 0;
1687
1810
  let key;
1688
- while ((key = _SIDE_MARGIN_KEY_RE.exec(keys))) {
1689
- const valueAt = span[0] + key.index + key[0].length;
1811
+ while ((key = _SIDE_MARGIN_KEY_RE.exec(keys.own))) {
1812
+ const valueAt = keys.at[key.index + key[0].length - 1] + 1;
1690
1813
  if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) return true;
1691
1814
  }
1692
1815
  return false;
@@ -1697,14 +1820,21 @@ function _fullWidthMarginRules(source) {
1697
1820
  const code = _stripNonCode(source, { keepStrings: true });
1698
1821
  // Strings blanked, so a brace inside a literal never unbalances the match.
1699
1822
  const structure = _stripNonCode(source);
1823
+ const index = _jsxScanIndex(structure);
1824
+ const partner = _bracePartners(structure);
1825
+ const margined = new Map();
1700
1826
  const sourceLines = source.split(/\r?\n/);
1827
+ const lineAt = _lineCounter(code);
1701
1828
  _FULL_WIDTH_RE.lastIndex = 0;
1702
1829
  let hit;
1703
1830
  while ((hit = _FULL_WIDTH_RE.exec(code))) {
1704
- const span = _enclosingObject(structure, hit.index);
1831
+ const span = index.enclosingObject(hit.index);
1705
1832
  if (!span) continue;
1706
- if (!_hasSideMargin(code, structure, span)) continue;
1707
- const line = code.slice(0, hit.index).split(/\r?\n/).length;
1833
+ if (!margined.has(span[0])) {
1834
+ margined.set(span[0], _hasSideMargin(code, _ownKeys(structure, partner, span)));
1835
+ }
1836
+ if (!margined.get(span[0])) continue;
1837
+ const line = lineAt(hit.index);
1708
1838
  findings.push({
1709
1839
  rule: "full-width-margin-overflow",
1710
1840
  severity: "warning",
@@ -1743,53 +1873,98 @@ function _styleEntryOffsets(structure) {
1743
1873
  return offsets;
1744
1874
  }
1745
1875
 
1746
- // The object literals a style expression spans, plus the entries it names.
1747
- function _styleObjectSpans(scan, [from, to]) {
1876
+ // What a style expression's objects say, ORed: the object literals it
1877
+ // spans, plus the named entries it references anywhere inside.
1878
+ function _styleRangeTraits(scan, [from, to]) {
1748
1879
  const { structure } = scan;
1749
- const spans = [];
1880
+ let row = false;
1881
+ let bordered = false;
1750
1882
  for (let i = from + 1; i < to - 1; i++) {
1751
1883
  if (structure[i] !== "{") continue;
1752
- const span = _enclosingObject(structure, i + 1);
1884
+ const span = scan.index.enclosingObject(i + 1);
1753
1885
  if (!span) continue;
1754
- spans.push(span);
1886
+ const traits = _styleObjectTraits(scan, span);
1887
+ row = row || traits.row;
1888
+ bordered = bordered || traits.bordered;
1755
1889
  i = span[1] - 1;
1756
1890
  }
1757
- if (!scan.entries) scan.entries = _styleEntryOffsets(structure);
1758
- const keys = new Set();
1891
+ const refs = _styleRefTraits(scan);
1892
+ const first = _firstAtOrAfter(refs.offsets, from);
1893
+ const last = _firstAtOrAfter(refs.offsets, to);
1894
+ return {
1895
+ row: row || refs.rows[last] > refs.rows[first],
1896
+ bordered: bordered || refs.bordered[last] > refs.bordered[first],
1897
+ };
1898
+ }
1899
+
1900
+ // Every `name.key` reference in the source, with running counts of those
1901
+ // naming a row / bordered entry, so a range is answered by subtraction
1902
+ // instead of re-reading it per nested parent (sc-8204).
1903
+ function _styleRefTraits(scan) {
1904
+ if (scan.refs) return scan.refs;
1905
+ if (!scan.entries) scan.entries = _styleEntryOffsets(scan.structure);
1906
+ const offsets = [];
1907
+ const rows = [0];
1908
+ const bordered = [0];
1759
1909
  _STYLE_REF_RE.lastIndex = 0;
1760
- const refs = structure.slice(from, to);
1761
1910
  let ref;
1762
- while ((ref = _STYLE_REF_RE.exec(refs))) keys.add(ref[1]);
1763
- for (const key of keys) {
1764
- const at = scan.entries.get(key);
1765
- const span = at === undefined ? null : _enclosingObject(structure, at);
1766
- if (span) spans.push(span);
1911
+ while ((ref = _STYLE_REF_RE.exec(scan.structure))) {
1912
+ const at = scan.entries.get(ref[1]);
1913
+ const span = at === undefined ? null : scan.index.enclosingObject(at);
1914
+ const traits = span ? _styleObjectTraits(scan, span) : null;
1915
+ offsets.push(ref.index);
1916
+ rows.push(rows[rows.length - 1] + (traits && traits.row ? 1 : 0));
1917
+ bordered.push(bordered[bordered.length - 1] + (traits && traits.bordered ? 1 : 0));
1918
+ }
1919
+ scan.refs = { offsets, rows, bordered };
1920
+ return scan.refs;
1921
+ }
1922
+
1923
+ function _firstAtOrAfter(sorted, value) {
1924
+ let lo = 0;
1925
+ let hi = sorted.length;
1926
+ while (lo < hi) {
1927
+ const mid = (lo + hi) >> 1;
1928
+ if (sorted[mid] < value) lo = mid + 1;
1929
+ else hi = mid;
1767
1930
  }
1768
- return spans;
1931
+ return lo;
1769
1932
  }
1770
1933
 
1771
1934
  function _isBorderedRow(scan, tag) {
1772
- const { code, structure } = scan;
1935
+ const { structure } = scan;
1773
1936
  const attrs = structure.slice(tag.attrs[0], tag.attrs[1]);
1774
1937
  const style = _STYLE_ATTR_RE.exec(attrs);
1775
1938
  if (!style) return false;
1776
1939
  const open = tag.attrs[0] + style.index + style[0].length - 1;
1777
- const inner = _bracedSpan(structure, open);
1778
- if (inner === null) return false;
1779
- let row = false;
1780
- let bordered = false;
1781
- // `inner` excludes its two braces.
1782
- for (const span of _styleObjectSpans(scan, [open, open + inner.length + 2])) {
1783
- const keys = _ownKeysMask(structure, span);
1784
- if (_ROW_KEY_RE.test(_ownKeysMask(code, span))) row = true;
1785
- _BORDER_WIDTH_KEY_RE.lastIndex = 0;
1786
- let key;
1787
- while ((key = _BORDER_WIDTH_KEY_RE.exec(keys))) {
1788
- const valueAt = span[0] + key.index + key[0].length;
1789
- if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) bordered = true;
1790
- }
1940
+ const close = scan.index.braceClose(open);
1941
+ if (close === -1) return false;
1942
+ const traits = _styleRangeTraits(scan, [open, close + 1]);
1943
+ return traits.row && traits.bordered;
1944
+ }
1945
+
1946
+ // Memoised per object: every row naming one shared style object would
1947
+ // otherwise re-read all of it, quadratic in the row count (sc-8204).
1948
+ function _styleObjectTraits(scan, span) {
1949
+ const known = scan.traits.get(span[0]);
1950
+ if (known) return known;
1951
+ const { code, structure } = scan;
1952
+ if (!scan.partners) {
1953
+ scan.partners = { code: _bracePartners(code), structure: _bracePartners(structure) };
1954
+ }
1955
+ const keys = _ownKeys(structure, scan.partners.structure, span);
1956
+ const traits = {
1957
+ row: _ROW_KEY_RE.test(_ownKeys(code, scan.partners.code, span).own),
1958
+ bordered: false,
1959
+ };
1960
+ _BORDER_WIDTH_KEY_RE.lastIndex = 0;
1961
+ let key;
1962
+ while ((key = _BORDER_WIDTH_KEY_RE.exec(keys.own))) {
1963
+ const valueAt = keys.at[key.index + key[0].length - 1] + 1;
1964
+ if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) traits.bordered = true;
1791
1965
  }
1792
- return row && bordered;
1966
+ scan.traits.set(span[0], traits);
1967
+ return traits;
1793
1968
  }
1794
1969
 
1795
1970
  function _selfClosing(structure, end) {
@@ -1798,13 +1973,46 @@ function _selfClosing(structure, end) {
1798
1973
  return structure[i] === "/";
1799
1974
  }
1800
1975
 
1976
+ // The open-element stack, with where each name sits on it, so a closing
1977
+ // tag finds its opener without scanning the stack per close (sc-8204).
1978
+ function _openTagStack() {
1979
+ const tags = [];
1980
+ const byName = new Map();
1981
+ const truncate = (length) => {
1982
+ while (tags.length > length) {
1983
+ const tag = tags.pop();
1984
+ if (tag) byName.get(tag.name).pop();
1985
+ }
1986
+ };
1987
+ return {
1988
+ top: () => tags[tags.length - 1],
1989
+ push: (tag) => {
1990
+ if (tag) {
1991
+ if (!byName.has(tag.name)) byName.set(tag.name, []);
1992
+ byName.get(tag.name).push(tags.length);
1993
+ }
1994
+ tags.push(tag);
1995
+ },
1996
+ pop: () => truncate(Math.max(0, tags.length - 1)),
1997
+ close: (name) => {
1998
+ const at = byName.get(name);
1999
+ if (at && at.length > 0) truncate(at[at.length - 1]);
2000
+ },
2001
+ };
2002
+ }
2003
+
1801
2004
  function _fieldInBorderedRowRules(source) {
1802
2005
  const findings = [];
1803
2006
  const scan = {
1804
2007
  code: _stripNonCode(source, { keepStrings: true }),
1805
2008
  structure: _stripNonCode(source),
1806
2009
  entries: null,
2010
+ index: null,
2011
+ traits: new Map(),
2012
+ partners: null,
2013
+ refs: null,
1807
2014
  };
2015
+ scan.index = _jsxScanIndex(scan.structure);
1808
2016
  const { code, structure } = scan;
1809
2017
  const sourceLines = source.split(/\r?\n/);
1810
2018
  // Tokens arrive in source order, so one running count serves every finding.
@@ -1824,7 +2032,7 @@ function _fieldInBorderedRowRules(source) {
1824
2032
  snippet: (sourceLines[n - 1] || "").trim().slice(0, 200),
1825
2033
  });
1826
2034
  };
1827
- const open = [];
2035
+ const open = _openTagStack();
1828
2036
  _JSX_TOKEN_RE.lastIndex = 0;
1829
2037
  let token;
1830
2038
  while ((token = _JSX_TOKEN_RE.exec(structure))) {
@@ -1837,15 +2045,14 @@ function _fieldInBorderedRowRules(source) {
1837
2045
  continue;
1838
2046
  }
1839
2047
  if (closing) {
1840
- const at = open.map((tag) => tag && tag.name).lastIndexOf(name);
1841
- if (at >= 0) open.length = at;
2048
+ open.close(name);
1842
2049
  continue;
1843
2050
  }
1844
- const end = _tagEnd(structure, token.index);
2051
+ const end = scan.index.tagEnd(token.index);
1845
2052
  if (end === -1) break;
1846
2053
  const tag = { name, attrs: [token.index + token[0].length, end], borderedRow: undefined };
1847
2054
  if (_FIELD_TAG_NAMES.includes(name)) {
1848
- const parent = open[open.length - 1];
2055
+ const parent = open.top();
1849
2056
  if (parent && parent.borderedRow === undefined) parent.borderedRow = _isBorderedRow(scan, parent);
1850
2057
  if (parent && parent.borderedRow) {
1851
2058
  report(
@@ -1885,27 +2092,42 @@ const _WIDTH_HOLDER_RE =
1885
2092
  const _LAYOUT_FEED_WINDOW = 80;
1886
2093
 
1887
2094
  function _measuredWidthNames(code) {
2095
+ const fedByLayout = _layoutFedCallees(code);
1888
2096
  const names = [];
1889
2097
  _WIDTH_HOLDER_RE.lastIndex = 0;
1890
2098
  let held;
1891
2099
  while ((held = _WIDTH_HOLDER_RE.exec(code))) {
1892
2100
  const [, value, setter, hook] = held;
1893
- if (hook === "useContainerWidth") {
2101
+ if (hook === "useContainerWidth" || fedByLayout.has(setter)) {
1894
2102
  names.push(value);
1895
- continue;
1896
2103
  }
1897
- const fedByLayout = new RegExp(
1898
- `\\b${setter}\\s*\\([\\s\\S]{0,${_LAYOUT_FEED_WINDOW}}?\\blayout\\.width\\b`,
1899
- );
1900
- if (fedByLayout.test(code)) names.push(value);
1901
2104
  }
1902
2105
  return names;
1903
2106
  }
1904
2107
 
1905
- function _sizesFromMeasuredWidth(code, names) {
1906
- return names.some((name) =>
1907
- new RegExp(`\\b${name}\\s*[-/*]|[-/*]\\s*\\b${name}\\b`).test(code),
2108
+ // Every callee with a layout width among its first arguments, in one pass:
2109
+ // a regex per declared setter rescanned the source per declaration (sc-8204).
2110
+ function _layoutFedCallees(code) {
2111
+ const fed = new Set();
2112
+ const call = /\b(\w+)\s*\(/g;
2113
+ const feed = new RegExp(
2114
+ `[\\s\\S]{0,${_LAYOUT_FEED_WINDOW}}?\\blayout\\.width\\b`,
2115
+ "y",
1908
2116
  );
2117
+ let callee;
2118
+ while ((callee = call.exec(code))) {
2119
+ feed.lastIndex = call.lastIndex;
2120
+ if (feed.test(code)) fed.add(callee[1]);
2121
+ }
2122
+ return fed;
2123
+ }
2124
+
2125
+ function _sizesFromMeasuredWidth(code, names) {
2126
+ if (names.length === 0) return false;
2127
+ const operands = new Set();
2128
+ for (const m of code.matchAll(/\b(\w+)\s*[-/*]/g)) operands.add(m[1]);
2129
+ for (const m of code.matchAll(/[-/*]\s*\b(\w+)\b/g)) operands.add(m[1]);
2130
+ return names.some((name) => operands.has(name));
1909
2131
  }
1910
2132
 
1911
2133
  function _measuredPaddingRules(source) {
@@ -1913,6 +2135,7 @@ function _measuredPaddingRules(source) {
1913
2135
  const measured = _measuredWidthNames(code);
1914
2136
  if (!_sizesFromMeasuredWidth(code, measured)) return [];
1915
2137
  const sourceLines = source.split(/\r?\n/);
2138
+ const lineAt = _lineCounter(code);
1916
2139
  const findings = [];
1917
2140
  _JSX_TAG_RE.lastIndex = 0;
1918
2141
  let tag;
@@ -1923,9 +2146,7 @@ function _measuredPaddingRules(source) {
1923
2146
  if (!_ON_LAYOUT_ATTR_RE.test(attrs)) continue;
1924
2147
  const padded = _OWN_PADDING_RE.exec(attrs);
1925
2148
  if (!padded) continue;
1926
- const line = code
1927
- .slice(0, tag.index + padded.index)
1928
- .split(/\r?\n/).length;
2149
+ const line = lineAt(tag.index + padded.index);
1929
2150
  findings.push({
1930
2151
  rule: "measured-width-ignores-padding",
1931
2152
  severity: "warning",
@@ -2081,6 +2302,17 @@ function _hardcodedDesignRules(source) {
2081
2302
  * map, an array of sources, or one source string
2082
2303
  * @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
2083
2304
  */
2305
+ // Whether `\b<name>\b` matches `code`, from one pass over its words: a
2306
+ // regex per styleSchema field re-read the whole bundle per field (sc-8204).
2307
+ function _wordReader(code) {
2308
+ const words = new Set(code.match(/\w+/g));
2309
+ return (name) => {
2310
+ if (!name.includes("$")) return words.has(name);
2311
+ // Unescaped, `$` asserts end of input, so only the tail can match.
2312
+ return new RegExp(`\\b${name}\\b`).test(code.slice(-(name.length + 1)));
2313
+ };
2314
+ }
2315
+
2084
2316
  function lintStyleWiring(manifest, files) {
2085
2317
  const empty = { ok: true, findings: [] };
2086
2318
  const schema = manifest && manifest.styleSchema;
@@ -2096,12 +2328,13 @@ function lintStyleWiring(manifest, files) {
2096
2328
  ? _WHOLE_WIDGET_STYLE_FIELDS
2097
2329
  : new Set();
2098
2330
  const findings = [];
2331
+ const isRead = _wordReader(code);
2099
2332
  for (const field of Object.keys(schema)) {
2100
2333
  // A key that is not a bare identifier cannot be scanned by name; the
2101
2334
  // manifest validator rejects those anyway.
2102
2335
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
2103
2336
  if (appliedByHelper.has(field)) continue;
2104
- if (new RegExp(`\\b${field}\\b`).test(code)) continue;
2337
+ if (isRead(field)) continue;
2105
2338
  findings.push({
2106
2339
  rule: "style-field-unread",
2107
2340
  severity: "warning",
@@ -2205,49 +2438,92 @@ function _isAuthoredCopy(text) {
2205
2438
  return !_SEPARATOR_GLYPHS.has(text);
2206
2439
  }
2207
2440
 
2208
- // The `>` that CLOSES a JSX tag, skipping quoted attribute values and any `>`
2209
- // inside a `{…}` expression — `onPress={() => x}` carries one, and treating it
2210
- // as the tag end reports the handler body as copy.
2211
- function _tagEnd(code, from) {
2212
- let depth = 0;
2213
- let quote = null;
2214
- for (let i = from; i < code.length; i++) {
2215
- const ch = code[i];
2216
- if (quote) {
2217
- if (ch === "\\") i++;
2218
- else if (ch === quote) quote = null;
2219
- continue;
2220
- }
2221
- if (ch === '"' || ch === "'" || ch === "`") quote = ch;
2222
- else if (ch === "{") depth++;
2223
- else if (ch === "}") depth--;
2224
- else if (ch === ">" && depth <= 0) return i;
2225
- }
2226
- return -1;
2441
+ // Tag ends and brace spans over a STRING-BLANKED mask, where quotes are
2442
+ // already gone, so only brace depth decides. Answered from one index per
2443
+ // source: a scan per match was quadratic, and a file of bare `<Text`
2444
+ // wedged the API pod for a minute (sc-8204).
2445
+ function _jsxScanIndex(mask) {
2446
+ const depth = new Int32Array(mask.length + 1);
2447
+ for (let i = 0; i < mask.length; i++) {
2448
+ const ch = mask[i];
2449
+ depth[i + 1] = depth[i] + (ch === "{" ? 1 : ch === "}" ? -1 : 0);
2450
+ }
2451
+ let tagEnds = null;
2452
+ let braceCloses = null;
2453
+ let braceOpens = null;
2454
+ const closes = () => {
2455
+ if (!braceCloses) braceCloses = _nextAtOrBelow(mask, depth, "}");
2456
+ return braceCloses;
2457
+ };
2458
+ return {
2459
+ // The `>` that CLOSES a JSX tag, skipping any `>` inside a `{…}`
2460
+ // expression — `onPress={() => x}` carries one, and treating it as the
2461
+ // tag end reports the handler body as copy.
2462
+ tagEnd: (from) => {
2463
+ if (!tagEnds) tagEnds = _nextAtOrBelow(mask, depth, ">");
2464
+ return tagEnds[from];
2465
+ },
2466
+ // The `}` balancing the `{` at `from`, or -1.
2467
+ braceClose: (from) => (mask[from] === "{" ? closes()[from + 1] : -1),
2468
+ // The innermost `{…}` around offset `at`, as [start, end), or null.
2469
+ enclosingObject: (at) => {
2470
+ if (!braceOpens) braceOpens = _previousOpenAtOrBelow(mask, depth);
2471
+ const start = braceOpens[at];
2472
+ const end = closes()[at];
2473
+ return start === -1 || end === -1 ? null : [start, end + 1];
2474
+ },
2475
+ };
2227
2476
  }
2228
2477
 
2229
- // The balanced `{…}` starting at `from`, or null.
2230
- function _bracedSpan(code, from) {
2231
- if (code[from] !== "{") return null;
2232
- let depth = 0;
2233
- let quote = null;
2234
- for (let i = from; i < code.length; i++) {
2235
- const ch = code[i];
2236
- if (quote) {
2237
- if (ch === "\\") i++;
2238
- else if (ch === quote) quote = null;
2239
- continue;
2478
+ // For every offset i, the first j >= i holding `ch` with depth[j] at or
2479
+ // below depth[i], else -1. One right-to-left pass keeps the candidates on a
2480
+ // stack; a nearer one no deeper dominates, so depth rises toward the top
2481
+ // and each query is a binary search.
2482
+ function _nextAtOrBelow(mask, depth, ch) {
2483
+ const n = mask.length;
2484
+ const nearest = new Int32Array(n + 1).fill(-1);
2485
+ const stack = new Int32Array(n);
2486
+ let top = 0;
2487
+ for (let i = n - 1; i >= 0; i--) {
2488
+ if (mask[i] === ch) {
2489
+ while (top > 0 && depth[stack[top - 1]] >= depth[i]) top--;
2490
+ stack[top++] = i;
2240
2491
  }
2241
- if (ch === '"' || ch === "'" || ch === "`") quote = ch;
2242
- else if (ch === "{") depth++;
2243
- else if (ch === "}") {
2244
- depth--;
2245
- if (depth === 0) return code.slice(from + 1, i);
2492
+ nearest[i] = _topmostAtOrBelow(stack, top, depth, 0, depth[i]);
2493
+ }
2494
+ return nearest;
2495
+ }
2496
+
2497
+ // The mirror for opening braces: for every offset k, the last `{` at i < k
2498
+ // whose inside, depth[i + 1], is at or below depth[k], else -1.
2499
+ function _previousOpenAtOrBelow(mask, depth) {
2500
+ const n = mask.length;
2501
+ const nearest = new Int32Array(n + 1).fill(-1);
2502
+ const stack = new Int32Array(n);
2503
+ let top = 0;
2504
+ for (let k = 0; k <= n; k++) {
2505
+ nearest[k] = _topmostAtOrBelow(stack, top, depth, 1, depth[k]);
2506
+ if (k < n && mask[k] === "{") {
2507
+ while (top > 0 && depth[stack[top - 1] + 1] >= depth[k + 1]) top--;
2508
+ stack[top++] = k;
2246
2509
  }
2247
2510
  }
2248
- return null;
2511
+ return nearest;
2249
2512
  }
2250
2513
 
2514
+ function _topmostAtOrBelow(stack, top, depth, shift, limit) {
2515
+ let lo = 0;
2516
+ let hi = top - 1;
2517
+ let found = -1;
2518
+ while (lo <= hi) {
2519
+ const mid = (lo + hi) >> 1;
2520
+ if (depth[stack[mid] + shift] <= limit) {
2521
+ found = stack[mid];
2522
+ lo = mid + 1;
2523
+ } else hi = mid - 1;
2524
+ }
2525
+ return found;
2526
+ }
2251
2527
  // The non-escape branch excludes `\` so the two branches can never both
2252
2528
  // match the same character. With a bare `.`, escape pairs before a quote
2253
2529
  // the anchor then rejects backtrack exponentially — 60 characters of an
@@ -2256,8 +2532,7 @@ function _bracedSpan(code, from) {
2256
2532
  //
2257
2533
  // It excludes the line terminators too, because a character class — unlike
2258
2534
  // `.` — matches them, which would widen the literal past its own line.
2259
- // An odd trailing backslash run therefore no longer matches; `_bracedSpan`
2260
- // shares the escape rule and never yields one.
2535
+ // An odd trailing backslash run therefore no longer matches.
2261
2536
  const _TEXT_LITERAL_CHILD_RE =
2262
2537
  /^(["'`])((?:\\.|(?!\1)[^\\\r\n\u2028\u2029])*)\1$/;
2263
2538
 
@@ -2273,16 +2548,23 @@ function _textLiteralOccurrences(codes, masks) {
2273
2548
  // not read as markup. Both masks preserve offsets, so the child is read
2274
2549
  // back from the kept-strings source at the very same index.
2275
2550
  const mask = masks[f];
2551
+ const index = _jsxScanIndex(mask);
2552
+ const lineAt = _lineCounter(code);
2553
+ // Unclosed tags share the next closing `>`, and so share its child: read
2554
+ // each child once, or `<Text <Text … >` re-reads it per tag.
2555
+ const readEnds = new Set();
2276
2556
  const re = /<Text\b/g;
2277
2557
  let m;
2278
2558
  while ((m = re.exec(mask)) !== null) {
2279
- const end = _tagEnd(mask, m.index);
2280
- if (end === -1) continue;
2559
+ const end = index.tagEnd(m.index);
2560
+ if (end === -1 || readEnds.has(end)) continue;
2561
+ readEnds.add(end);
2281
2562
  let i = end + 1;
2282
2563
  while (i < code.length && /\s/.test(code[i])) i++;
2283
2564
  let text = null;
2284
2565
  if (code[i] === "{") {
2285
- const inner = _bracedSpan(code, i);
2566
+ const close = index.braceClose(i);
2567
+ const inner = close === -1 ? null : code.slice(i + 1, close);
2286
2568
  const lit = inner && inner.trim().match(_TEXT_LITERAL_CHILD_RE);
2287
2569
  // A template carrying `${…}` interpolates a value, so it is not a literal.
2288
2570
  if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
@@ -2294,7 +2576,7 @@ function _textLiteralOccurrences(codes, masks) {
2294
2576
  text = text.trim();
2295
2577
  if (!_isAuthoredCopy(text) || byText.has(text)) continue;
2296
2578
  // Split-impl carries the same copy twice; report the string, not the file.
2297
- byText.set(text, code.slice(0, i).split("\n").length);
2579
+ byText.set(text, lineAt(i));
2298
2580
  }
2299
2581
  }
2300
2582
  return [...byText.entries()].map(([text, line]) => ({ text, line }));