@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.js CHANGED
@@ -408,15 +408,22 @@ const _ASSET_SPECIFIER_RE = /\.(css|png|jpe?g|gif)$/i;
408
408
  const _ICON_DEFAULT_CONFIGURED_RE =
409
409
  /\bIcon\s*\.\s*Default\s*(\.\s*mergeOptions\s*\(|\.\s*imagePath\s*=)/;
410
410
 
411
- // Line of an index in `code`, counted forward: indexes must not decrease.
411
+ // 1-based line of any index in `code`, by binary search over the newline
412
+ // offsets: counting from the top per finding is quadratic (sc-8204).
412
413
  function _lineCounter(code) {
413
- let line = 1;
414
- let at = 0;
414
+ const newlines = [];
415
+ for (let i = code.indexOf("\n"); i !== -1; i = code.indexOf("\n", i + 1)) {
416
+ newlines.push(i);
417
+ }
415
418
  return (index) => {
416
- for (; at < index; at += 1) {
417
- if (code.charCodeAt(at) === 10) line += 1;
419
+ let lo = 0;
420
+ let hi = newlines.length;
421
+ while (lo < hi) {
422
+ const mid = (lo + hi) >> 1;
423
+ if (newlines[mid] < index) lo = mid + 1;
424
+ else hi = mid;
418
425
  }
419
- return line;
426
+ return lo + 1;
420
427
  };
421
428
  }
422
429
 
@@ -464,6 +471,9 @@ function _leafletRules(source) {
464
471
  let tag;
465
472
  while ((tag = _MARKER_TAG_RE.exec(code))) {
466
473
  const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
474
+ // Resume past the tag even when it passes: rescanning a tag nested in
475
+ // its attributes made unclosed runs quadratic (sc-8204).
476
+ _MARKER_TAG_RE.lastIndex = end;
467
477
  if (/(^|\s)icon\s*=/.test(code.slice(tag.index, end))) continue;
468
478
  const line = markerLineAt(tag.index);
469
479
  findings.push({
@@ -481,7 +491,6 @@ function _leafletRules(source) {
481
491
  line,
482
492
  snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
483
493
  });
484
- _MARKER_TAG_RE.lastIndex = end;
485
494
  }
486
495
  return findings;
487
496
  }
@@ -1023,8 +1032,32 @@ const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
1023
1032
  // `[^}]*` (not `[\s\S]*?`) so the brace capture can never cross a `}` into a
1024
1033
  // neighbouring import — otherwise a preceding `import { View, Pressable } from
1025
1034
  // "react-native"` is swallowed and its names get validated as lucide icons.
1026
- const LUCIDE_IMPORT_RE =
1027
- /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([^}]*)\}\s*from\s*["']lucide-react-native["']/g;
1035
+ const LUCIDE_IMPORT_HEAD_RE = /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{/g;
1036
+ const LUCIDE_IMPORT_TAIL_RE = /\}\s*from\s*["']lucide-react-native["']/y;
1037
+
1038
+ // `import { … } from "lucide-react-native"` clauses. The names run to the
1039
+ // NEXT `}`, so every head sharing that brace shares one tail check; one
1040
+ // regex retried that run from each unclosed `import {` (sc-8204).
1041
+ function _lucideImports(source) {
1042
+ const found = [];
1043
+ const tails = new Map();
1044
+ LUCIDE_IMPORT_HEAD_RE.lastIndex = 0;
1045
+ let head;
1046
+ while ((head = LUCIDE_IMPORT_HEAD_RE.exec(source))) {
1047
+ const from = LUCIDE_IMPORT_HEAD_RE.lastIndex;
1048
+ const close = source.indexOf("}", from);
1049
+ if (close === -1) break;
1050
+ if (!tails.has(close)) {
1051
+ LUCIDE_IMPORT_TAIL_RE.lastIndex = close;
1052
+ tails.set(close, LUCIDE_IMPORT_TAIL_RE.test(source) ? LUCIDE_IMPORT_TAIL_RE.lastIndex : -1);
1053
+ }
1054
+ const end = tails.get(close);
1055
+ if (end === -1) continue;
1056
+ found.push({ index: head.index, names: source.slice(from, close) });
1057
+ LUCIDE_IMPORT_HEAD_RE.lastIndex = end;
1058
+ }
1059
+ return found;
1060
+ }
1028
1061
 
1029
1062
  // lucide re-exports each base icon as `<Name>`, `<Name>Icon`, and `Lucide<Name>`;
1030
1063
  // the committed set holds only the base names, so normalise the alias forms
@@ -1040,11 +1073,10 @@ function _isValidLucideName(name) {
1040
1073
  function _lucideIconRules(source) {
1041
1074
  const findings = [];
1042
1075
  const sourceLines = source.split(/\r?\n/);
1043
- LUCIDE_IMPORT_RE.lastIndex = 0;
1044
- let m;
1045
- while ((m = LUCIDE_IMPORT_RE.exec(source))) {
1046
- const line = source.slice(0, m.index).split(/\r?\n/).length;
1047
- for (const raw of (m[1] || "").split(",")) {
1076
+ const lineAt = _lineCounter(source);
1077
+ for (const m of _lucideImports(source)) {
1078
+ const line = lineAt(m.index);
1079
+ for (const raw of m.names.split(",")) {
1048
1080
  // Strip any inline comment from the specifier token so a `{ Home, // x`
1049
1081
  // comment can't glue onto and hide the next specifier (e.g. `House`).
1050
1082
  const cleaned = raw
@@ -1127,6 +1159,25 @@ const _EXPRESSION_LEAD_WORDS = new Set([
1127
1159
  "instanceof",
1128
1160
  ]);
1129
1161
 
1162
+ // The offset just past the whitespace run ending before `at`.
1163
+ function _skipSpaceBack(code, at) {
1164
+ while (at > 0 && /\s/.test(code[at - 1])) at--;
1165
+ return at;
1166
+ }
1167
+
1168
+ function _skipSpaceForward(code, at) {
1169
+ while (at < code.length && /\s/.test(code[at])) at++;
1170
+ return at;
1171
+ }
1172
+
1173
+ // The identifier ending at `last`; leading digits are not part of it.
1174
+ function _wordEndingAt(code, last) {
1175
+ let start = last + 1;
1176
+ while (start > 0 && /[A-Za-z0-9_$]/.test(code[start - 1])) start--;
1177
+ while (start <= last && /[0-9]/.test(code[start])) start++;
1178
+ return code.slice(start, last + 1);
1179
+ }
1180
+
1130
1181
  // 1-based line of the first unbound reference to `name`, or 0 when the name is
1131
1182
  // bound, never referenced, or referenced in a form this scan cannot decide.
1132
1183
  function _unboundReferenceLine(code, name) {
@@ -1136,22 +1187,24 @@ function _unboundReferenceLine(code, name) {
1136
1187
  const word = new RegExp(`\\b${name}\\b`, "g");
1137
1188
  let firstIndex = -1;
1138
1189
  let match;
1190
+ // Every look-around walks a few characters from the match: slicing and
1191
+ // regex-testing the whole prefix per match was quadratic (sc-8204).
1139
1192
  while ((match = word.exec(code))) {
1140
- const before = code.slice(0, match.index).replace(/\s+$/, "");
1193
+ const leadAt = _skipSpaceBack(code, match.index) - 1;
1194
+ const lead = leadAt >= 0 ? code[leadAt] : "";
1141
1195
  // `obj.NAME` / `obj?.NAME` / `{...NAME}` — never this binding.
1142
- if (before.endsWith(".")) continue;
1143
- const lead = before.slice(-1);
1196
+ if (lead === ".") continue;
1144
1197
  if (/[A-Za-z0-9_$]/.test(lead)) {
1145
- const leadWord = (before.match(/[A-Za-z_$][A-Za-z0-9_$]*$/) || [""])[0];
1146
- if (!_EXPRESSION_LEAD_WORDS.has(leadWord)) return 0;
1147
- } else if (lead === ">" && !before.endsWith("=>")) {
1198
+ if (!_EXPRESSION_LEAD_WORDS.has(_wordEndingAt(code, leadAt))) return 0;
1199
+ } else if (lead === ">" && code[leadAt - 1] !== "=") {
1148
1200
  // A `>` that is not an arrow closes a JSX tag, so what follows is text.
1149
1201
  return 0;
1150
1202
  }
1151
- const after = code.slice(match.index + name.length).replace(/^\s+/, "");
1152
- const isReference =
1153
- /<\s*\/?$/.test(before) || after.startsWith("(") || after.startsWith(".");
1154
- if (!isReference) return 0;
1203
+ const opensTag =
1204
+ lead === "<" ||
1205
+ (lead === "/" && code[_skipSpaceBack(code, leadAt) - 1] === "<");
1206
+ const next = code[_skipSpaceForward(code, match.index + name.length)];
1207
+ if (!opensTag && next !== "(" && next !== ".") return 0;
1155
1208
  if (firstIndex < 0) firstIndex = match.index;
1156
1209
  }
1157
1210
  if (firstIndex < 0) return 0;
@@ -1247,10 +1300,11 @@ function _bundledAssetRules(source, assetFiles) {
1247
1300
  // Comments blanked, strings kept: the file name IS a string literal.
1248
1301
  const code = _stripNonCode(source, { keepStrings: true });
1249
1302
  const sourceLines = source.split(/\r?\n/);
1303
+ const lineAt = _lineCounter(code);
1250
1304
  for (const match of code.matchAll(BUNDLED_ASSET_CALL_RE)) {
1251
1305
  const name = match[1] || match[2] || match[3] || "";
1252
1306
  if (name.includes("${") || assetFiles.includes(name)) continue;
1253
- const line = code.slice(0, match.index).split(/\r?\n/).length;
1307
+ const line = lineAt(match.index);
1254
1308
  findings.push({
1255
1309
  rule: "bundled-asset-missing",
1256
1310
  severity: "error",
@@ -1291,6 +1345,7 @@ function _paymentCurrencyRules(source) {
1291
1345
  // Comments blanked, strings kept: the currency code IS a string literal.
1292
1346
  const code = _stripNonCode(source, { keepStrings: true });
1293
1347
  const sourceLines = source.split(/\r?\n/);
1348
+ const lineAt = _lineCounter(code);
1294
1349
  let from = 0;
1295
1350
  for (;;) {
1296
1351
  const at = code.indexOf(REQUEST_PAYMENT_CALL, from);
@@ -1301,7 +1356,7 @@ function _paymentCurrencyRules(source) {
1301
1356
  );
1302
1357
  if (!match) continue;
1303
1358
  // Line of the currency literal itself, not of the call.
1304
- const line = code.slice(0, at + match.index).split(/\r?\n/).length;
1359
+ const line = lineAt(at + match.index);
1305
1360
  findings.push({
1306
1361
  rule: "payment-currency",
1307
1362
  severity: "error",
@@ -1480,22 +1535,36 @@ function _datastoreErrorHandlingRules(source) {
1480
1535
  // the deps array is wrong (sc-1579's volatile-query gate exists because of
1481
1536
  // exactly this mistake). Flags only when the file has NOT already reached
1482
1537
  // for `useStableQuery`, the hook that removes the deps array entirely.
1538
+ // The first `const NAME = useMemo(` of every name, in one pass: a regex
1539
+ // per queried name rescanned the source per call (sc-8204). A name holding
1540
+ // `$` never matched the old per-name pattern, so it is left out.
1541
+ function _firstUseMemoConsts(code) {
1542
+ const first = new Map();
1543
+ const re = /\bconst\s+([A-Za-z_][\w$]*)\s*=\s*useMemo\s*\(/g;
1544
+ let m;
1545
+ while ((m = re.exec(code))) {
1546
+ if (!m[1].includes("$") && !first.has(m[1])) first.set(m[1], m.index);
1547
+ }
1548
+ return first;
1549
+ }
1550
+
1483
1551
  function _rawUseMemoIntoDatastoreQueryRules(source) {
1484
1552
  const code = _stripNonCode(source);
1485
1553
  if (/\buseStableQuery\s*\(/.test(code)) return [];
1486
1554
  const callRe = /\buseDatastoreQuery\s*\(\s*[^,()]+,\s*([A-Za-z_$][\w$]*)\s*[,)]/g;
1487
1555
  const findings = [];
1488
1556
  const sourceLines = source.split(/\r?\n/);
1557
+ const lineAt = _lineCounter(code);
1558
+ const memoAt = _firstUseMemoConsts(code);
1489
1559
  const seen = new Set();
1490
1560
  let m;
1491
1561
  while ((m = callRe.exec(code))) {
1492
1562
  const varName = m[1];
1493
1563
  if (seen.has(varName)) continue;
1494
- const memoRe = new RegExp(`\\bconst\\s+${varName}\\s*=\\s*useMemo\\s*\\(`);
1495
- const memoMatch = memoRe.exec(code);
1496
- if (!memoMatch || memoMatch.index >= m.index) continue;
1564
+ const memoIndex = memoAt.get(varName);
1565
+ if (memoIndex === undefined || memoIndex >= m.index) continue;
1497
1566
  seen.add(varName);
1498
- const line = code.slice(0, memoMatch.index).split(/\r?\n/).length;
1567
+ const line = lineAt(memoIndex);
1499
1568
  findings.push({
1500
1569
  rule: "raw-useMemo-into-datastore-query",
1501
1570
  severity: "warning",
@@ -1568,32 +1637,83 @@ function _rowActionGatingRules(source) {
1568
1637
  // The useDatastoreMutation() call whose update or delete the widget uses,
1569
1638
  // either as `m.update(` / `m.delete(` or destructured `{ update, delete }`.
1570
1639
  function _rowMutationCall(code) {
1571
- const hookCall = /(?:\{([^}]*)\}|\b(\w+))\s*=\s*useDatastoreMutation\s*\(/g;
1572
- for (const match of code.matchAll(hookCall)) {
1573
- const [, destructured, name] = match;
1574
- const usesRowVerb = destructured
1575
- ? /\b(update|delete)\b/.test(destructured)
1576
- : new RegExp(`\\b${name}\\s*\\.\\s*(update|delete)\\s*\\(`).test(code);
1577
- if (usesRowVerb) return match;
1640
+ const rowVerbCallers = new Set();
1641
+ for (const m of code.matchAll(/\b(\w+)\s*\.\s*(?:update|delete)\s*\(/g)) {
1642
+ rowVerbCallers.add(m[1]);
1643
+ }
1644
+ for (const call of _mutationHookCalls(code)) {
1645
+ // An empty `{}` read as a name before: the regex built "undefined".
1646
+ const usesRowVerb = call.destructured
1647
+ ? /\b(update|delete)\b/.test(call.destructured)
1648
+ : rowVerbCallers.has(String(call.name));
1649
+ if (usesRowVerb) return call;
1578
1650
  }
1579
1651
  return null;
1580
1652
  }
1581
1653
 
1654
+ const _MUTATION_TAIL_RE = /\}\s*=\s*useDatastoreMutation\s*\(/y;
1655
+ const _NAMED_MUTATION_RE = /\b(\w+)\s*=\s*useDatastoreMutation\s*\(/g;
1656
+
1657
+ // Every `{ … } = useDatastoreMutation(` / `name = useDatastoreMutation(`, in
1658
+ // source order, as one `(?:\{([^}]*)\}|\b(\w+))…` regex would match them.
1659
+ // The destructured names run to the NEXT `}`, so each `}` is checked once;
1660
+ // the regex re-read that run from every unclosed `{` (sc-8204).
1661
+ function _mutationHookCalls(code) {
1662
+ const calls = [];
1663
+ const tails = new Map();
1664
+ let named = null;
1665
+ let brace = code.indexOf("{");
1666
+ let close = -1;
1667
+ for (let from = 0; ; ) {
1668
+ if (named === null || named.index < from) {
1669
+ _NAMED_MUTATION_RE.lastIndex = from;
1670
+ named = _NAMED_MUTATION_RE.exec(code) || { index: Infinity };
1671
+ }
1672
+ let destructured = null;
1673
+ for (; brace !== -1 && brace < named.index; brace = code.indexOf("{", brace + 1)) {
1674
+ if (brace < from) continue;
1675
+ if (close <= brace) close = code.indexOf("}", brace + 1);
1676
+ if (close === -1) {
1677
+ brace = -1;
1678
+ break;
1679
+ }
1680
+ if (!tails.has(close)) {
1681
+ _MUTATION_TAIL_RE.lastIndex = close;
1682
+ tails.set(close, _MUTATION_TAIL_RE.test(code) ? _MUTATION_TAIL_RE.lastIndex : -1);
1683
+ }
1684
+ if (tails.get(close) !== -1) {
1685
+ destructured = { index: brace, destructured: code.slice(brace + 1, close) };
1686
+ break;
1687
+ }
1688
+ }
1689
+ if (destructured) {
1690
+ calls.push(destructured);
1691
+ from = tails.get(close);
1692
+ } else if (named.index !== Infinity) {
1693
+ calls.push({ index: named.index, name: named[1] });
1694
+ from = named.index + named[0].length;
1695
+ } else return calls;
1696
+ }
1697
+ }
1698
+
1582
1699
  function _imagePercentHeightRules(source) {
1583
1700
  const findings = [];
1584
1701
  // Comments are blanked (string contents kept) so a commented-out example —
1585
1702
  // including the one in this rule's own docs — is never flagged.
1586
1703
  const code = _stripNonCode(source, { keepStrings: true });
1587
1704
  const sourceLines = source.split(/\r?\n/);
1705
+ const lineAt = _lineCounter(code);
1588
1706
  _IMAGE_TAG_RE.lastIndex = 0;
1589
1707
  let tag;
1590
1708
  while ((tag = _IMAGE_TAG_RE.exec(code))) {
1591
1709
  const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
1710
+ // A tag nested in these attributes is inside `attrs` already (sc-8204).
1711
+ _IMAGE_TAG_RE.lastIndex = end;
1592
1712
  const attrs = code.slice(tag.index, end);
1593
1713
  _PERCENT_HEIGHT_RE.lastIndex = 0;
1594
1714
  const hit = _PERCENT_HEIGHT_RE.exec(attrs);
1595
1715
  if (!hit) continue;
1596
- const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
1716
+ const line = lineAt(tag.index + hit.index);
1597
1717
  findings.push({
1598
1718
  rule: "image-percent-height",
1599
1719
  severity: "warning",
@@ -1608,9 +1728,6 @@ function _imagePercentHeightRules(source) {
1608
1728
  line,
1609
1729
  snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
1610
1730
  });
1611
- // One finding per <Image>; a second percentage height on the same tag is
1612
- // the same defect.
1613
- _IMAGE_TAG_RE.lastIndex = end;
1614
1731
  }
1615
1732
  return findings;
1616
1733
  }
@@ -1649,10 +1766,11 @@ function _flexBasisPercentRules(source) {
1649
1766
  // so this rule's own documentation must not flag itself.
1650
1767
  const code = _stripNonCode(source, { keepStrings: true });
1651
1768
  const sourceLines = source.split(/\r?\n/);
1769
+ const lineAt = _lineCounter(code);
1652
1770
  _FLEX_BASIS_PERCENT_RE.lastIndex = 0;
1653
1771
  let hit;
1654
1772
  while ((hit = _FLEX_BASIS_PERCENT_RE.exec(code))) {
1655
- const line = code.slice(0, hit.index).split(/\r?\n/).length;
1773
+ const line = lineAt(hit.index);
1656
1774
  findings.push({
1657
1775
  rule: "flex-basis-percent",
1658
1776
  severity: "warning",
@@ -1687,42 +1805,45 @@ const _SIDE_MARGIN_KEY_RE =
1687
1805
  const _ZERO_VALUE_RE =
1688
1806
  /^\s*(?:(["'`]?)-?0(?:\.0*)?(?:px)?\1\s*(?:[,}]|$)|(?:undefined|null)\b)/m;
1689
1807
 
1690
- // The innermost `{ … }` around `at`, as [start, end); null outside any.
1691
- function _enclosingObject(code, at) {
1692
- let start = -1;
1693
- for (let i = at - 1, depth = 0; i >= 0; i--) {
1694
- if (code[i] === "}") depth++;
1695
- else if (code[i] === "{" && depth-- === 0) {
1696
- start = i;
1697
- break;
1808
+
1809
+ // The object's own keys only: a nested object's margin is not this box's.
1810
+ // Blanked rather than cut, so an offset here is the same offset in the source.
1811
+ // Partner offset of every brace matched by stack in `text`, else -1.
1812
+ function _bracePartners(text) {
1813
+ const partner = new Int32Array(text.length).fill(-1);
1814
+ const opens = [];
1815
+ for (let i = 0; i < text.length; i++) {
1816
+ if (text[i] === "{") opens.push(i);
1817
+ else if (text[i] === "}" && opens.length > 0) {
1818
+ const open = opens.pop();
1819
+ partner[open] = i;
1820
+ partner[i] = open;
1698
1821
  }
1699
1822
  }
1700
- if (start < 0) return null;
1701
- for (let j = at, depth = 0; j < code.length; j++) {
1702
- if (code[j] === "{") depth++;
1703
- else if (code[j] === "}" && depth-- === 0) return [start, j + 1];
1823
+ return partner;
1824
+ }
1825
+
1826
+ // An object's own level: each nested `{…}` collapsed to one space, with the
1827
+ // offset in `text` of every character kept. Walking only that level means
1828
+ // an ancestor never re-reads its descendants (sc-8204).
1829
+ function _ownKeys(text, partner, [start, end]) {
1830
+ let own = "";
1831
+ const at = [];
1832
+ for (let i = start; i < end; i++) {
1833
+ const close = partner[i];
1834
+ const nested = text[i] === "{" && i !== start && close > i && close < end - 1;
1835
+ own += nested ? " " : text[i];
1836
+ at.push(i);
1837
+ if (nested) i = close;
1704
1838
  }
1705
- return null;
1839
+ return { own, at };
1706
1840
  }
1707
1841
 
1708
- // The object's own keys only: a nested object's margin is not this box's.
1709
- // Blanked rather than cut, so an offset here is the same offset in the source.
1710
- function _ownKeysMask(structure, [start, end]) {
1711
- let body = structure.slice(start, end);
1712
- let previous;
1713
- do {
1714
- previous = body;
1715
- body = body.replace(/(?<!^)\{[^{}]*\}(?!$)/g, (nested) => " ".repeat(nested.length));
1716
- } while (body !== previous);
1717
- return body;
1718
- }
1719
-
1720
- function _hasSideMargin(code, structure, span) {
1721
- const keys = _ownKeysMask(structure, span);
1842
+ function _hasSideMargin(code, keys) {
1722
1843
  _SIDE_MARGIN_KEY_RE.lastIndex = 0;
1723
1844
  let key;
1724
- while ((key = _SIDE_MARGIN_KEY_RE.exec(keys))) {
1725
- const valueAt = span[0] + key.index + key[0].length;
1845
+ while ((key = _SIDE_MARGIN_KEY_RE.exec(keys.own))) {
1846
+ const valueAt = keys.at[key.index + key[0].length - 1] + 1;
1726
1847
  if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) return true;
1727
1848
  }
1728
1849
  return false;
@@ -1733,14 +1854,21 @@ function _fullWidthMarginRules(source) {
1733
1854
  const code = _stripNonCode(source, { keepStrings: true });
1734
1855
  // Strings blanked, so a brace inside a literal never unbalances the match.
1735
1856
  const structure = _stripNonCode(source);
1857
+ const index = _jsxScanIndex(structure);
1858
+ const partner = _bracePartners(structure);
1859
+ const margined = new Map();
1736
1860
  const sourceLines = source.split(/\r?\n/);
1861
+ const lineAt = _lineCounter(code);
1737
1862
  _FULL_WIDTH_RE.lastIndex = 0;
1738
1863
  let hit;
1739
1864
  while ((hit = _FULL_WIDTH_RE.exec(code))) {
1740
- const span = _enclosingObject(structure, hit.index);
1865
+ const span = index.enclosingObject(hit.index);
1741
1866
  if (!span) continue;
1742
- if (!_hasSideMargin(code, structure, span)) continue;
1743
- const line = code.slice(0, hit.index).split(/\r?\n/).length;
1867
+ if (!margined.has(span[0])) {
1868
+ margined.set(span[0], _hasSideMargin(code, _ownKeys(structure, partner, span)));
1869
+ }
1870
+ if (!margined.get(span[0])) continue;
1871
+ const line = lineAt(hit.index);
1744
1872
  findings.push({
1745
1873
  rule: "full-width-margin-overflow",
1746
1874
  severity: "warning",
@@ -1779,53 +1907,98 @@ function _styleEntryOffsets(structure) {
1779
1907
  return offsets;
1780
1908
  }
1781
1909
 
1782
- // The object literals a style expression spans, plus the entries it names.
1783
- function _styleObjectSpans(scan, [from, to]) {
1910
+ // What a style expression's objects say, ORed: the object literals it
1911
+ // spans, plus the named entries it references anywhere inside.
1912
+ function _styleRangeTraits(scan, [from, to]) {
1784
1913
  const { structure } = scan;
1785
- const spans = [];
1914
+ let row = false;
1915
+ let bordered = false;
1786
1916
  for (let i = from + 1; i < to - 1; i++) {
1787
1917
  if (structure[i] !== "{") continue;
1788
- const span = _enclosingObject(structure, i + 1);
1918
+ const span = scan.index.enclosingObject(i + 1);
1789
1919
  if (!span) continue;
1790
- spans.push(span);
1920
+ const traits = _styleObjectTraits(scan, span);
1921
+ row = row || traits.row;
1922
+ bordered = bordered || traits.bordered;
1791
1923
  i = span[1] - 1;
1792
1924
  }
1793
- if (!scan.entries) scan.entries = _styleEntryOffsets(structure);
1794
- const keys = new Set();
1925
+ const refs = _styleRefTraits(scan);
1926
+ const first = _firstAtOrAfter(refs.offsets, from);
1927
+ const last = _firstAtOrAfter(refs.offsets, to);
1928
+ return {
1929
+ row: row || refs.rows[last] > refs.rows[first],
1930
+ bordered: bordered || refs.bordered[last] > refs.bordered[first],
1931
+ };
1932
+ }
1933
+
1934
+ // Every `name.key` reference in the source, with running counts of those
1935
+ // naming a row / bordered entry, so a range is answered by subtraction
1936
+ // instead of re-reading it per nested parent (sc-8204).
1937
+ function _styleRefTraits(scan) {
1938
+ if (scan.refs) return scan.refs;
1939
+ if (!scan.entries) scan.entries = _styleEntryOffsets(scan.structure);
1940
+ const offsets = [];
1941
+ const rows = [0];
1942
+ const bordered = [0];
1795
1943
  _STYLE_REF_RE.lastIndex = 0;
1796
- const refs = structure.slice(from, to);
1797
1944
  let ref;
1798
- while ((ref = _STYLE_REF_RE.exec(refs))) keys.add(ref[1]);
1799
- for (const key of keys) {
1800
- const at = scan.entries.get(key);
1801
- const span = at === undefined ? null : _enclosingObject(structure, at);
1802
- if (span) spans.push(span);
1945
+ while ((ref = _STYLE_REF_RE.exec(scan.structure))) {
1946
+ const at = scan.entries.get(ref[1]);
1947
+ const span = at === undefined ? null : scan.index.enclosingObject(at);
1948
+ const traits = span ? _styleObjectTraits(scan, span) : null;
1949
+ offsets.push(ref.index);
1950
+ rows.push(rows[rows.length - 1] + (traits && traits.row ? 1 : 0));
1951
+ bordered.push(bordered[bordered.length - 1] + (traits && traits.bordered ? 1 : 0));
1952
+ }
1953
+ scan.refs = { offsets, rows, bordered };
1954
+ return scan.refs;
1955
+ }
1956
+
1957
+ function _firstAtOrAfter(sorted, value) {
1958
+ let lo = 0;
1959
+ let hi = sorted.length;
1960
+ while (lo < hi) {
1961
+ const mid = (lo + hi) >> 1;
1962
+ if (sorted[mid] < value) lo = mid + 1;
1963
+ else hi = mid;
1803
1964
  }
1804
- return spans;
1965
+ return lo;
1805
1966
  }
1806
1967
 
1807
1968
  function _isBorderedRow(scan, tag) {
1808
- const { code, structure } = scan;
1969
+ const { structure } = scan;
1809
1970
  const attrs = structure.slice(tag.attrs[0], tag.attrs[1]);
1810
1971
  const style = _STYLE_ATTR_RE.exec(attrs);
1811
1972
  if (!style) return false;
1812
1973
  const open = tag.attrs[0] + style.index + style[0].length - 1;
1813
- const inner = _bracedSpan(structure, open);
1814
- if (inner === null) return false;
1815
- let row = false;
1816
- let bordered = false;
1817
- // `inner` excludes its two braces.
1818
- for (const span of _styleObjectSpans(scan, [open, open + inner.length + 2])) {
1819
- const keys = _ownKeysMask(structure, span);
1820
- if (_ROW_KEY_RE.test(_ownKeysMask(code, span))) row = true;
1821
- _BORDER_WIDTH_KEY_RE.lastIndex = 0;
1822
- let key;
1823
- while ((key = _BORDER_WIDTH_KEY_RE.exec(keys))) {
1824
- const valueAt = span[0] + key.index + key[0].length;
1825
- if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) bordered = true;
1826
- }
1974
+ const close = scan.index.braceClose(open);
1975
+ if (close === -1) return false;
1976
+ const traits = _styleRangeTraits(scan, [open, close + 1]);
1977
+ return traits.row && traits.bordered;
1978
+ }
1979
+
1980
+ // Memoised per object: every row naming one shared style object would
1981
+ // otherwise re-read all of it, quadratic in the row count (sc-8204).
1982
+ function _styleObjectTraits(scan, span) {
1983
+ const known = scan.traits.get(span[0]);
1984
+ if (known) return known;
1985
+ const { code, structure } = scan;
1986
+ if (!scan.partners) {
1987
+ scan.partners = { code: _bracePartners(code), structure: _bracePartners(structure) };
1988
+ }
1989
+ const keys = _ownKeys(structure, scan.partners.structure, span);
1990
+ const traits = {
1991
+ row: _ROW_KEY_RE.test(_ownKeys(code, scan.partners.code, span).own),
1992
+ bordered: false,
1993
+ };
1994
+ _BORDER_WIDTH_KEY_RE.lastIndex = 0;
1995
+ let key;
1996
+ while ((key = _BORDER_WIDTH_KEY_RE.exec(keys.own))) {
1997
+ const valueAt = keys.at[key.index + key[0].length - 1] + 1;
1998
+ if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) traits.bordered = true;
1827
1999
  }
1828
- return row && bordered;
2000
+ scan.traits.set(span[0], traits);
2001
+ return traits;
1829
2002
  }
1830
2003
 
1831
2004
  function _selfClosing(structure, end) {
@@ -1834,13 +2007,46 @@ function _selfClosing(structure, end) {
1834
2007
  return structure[i] === "/";
1835
2008
  }
1836
2009
 
2010
+ // The open-element stack, with where each name sits on it, so a closing
2011
+ // tag finds its opener without scanning the stack per close (sc-8204).
2012
+ function _openTagStack() {
2013
+ const tags = [];
2014
+ const byName = new Map();
2015
+ const truncate = (length) => {
2016
+ while (tags.length > length) {
2017
+ const tag = tags.pop();
2018
+ if (tag) byName.get(tag.name).pop();
2019
+ }
2020
+ };
2021
+ return {
2022
+ top: () => tags[tags.length - 1],
2023
+ push: (tag) => {
2024
+ if (tag) {
2025
+ if (!byName.has(tag.name)) byName.set(tag.name, []);
2026
+ byName.get(tag.name).push(tags.length);
2027
+ }
2028
+ tags.push(tag);
2029
+ },
2030
+ pop: () => truncate(Math.max(0, tags.length - 1)),
2031
+ close: (name) => {
2032
+ const at = byName.get(name);
2033
+ if (at && at.length > 0) truncate(at[at.length - 1]);
2034
+ },
2035
+ };
2036
+ }
2037
+
1837
2038
  function _fieldInBorderedRowRules(source) {
1838
2039
  const findings = [];
1839
2040
  const scan = {
1840
2041
  code: _stripNonCode(source, { keepStrings: true }),
1841
2042
  structure: _stripNonCode(source),
1842
2043
  entries: null,
2044
+ index: null,
2045
+ traits: new Map(),
2046
+ partners: null,
2047
+ refs: null,
1843
2048
  };
2049
+ scan.index = _jsxScanIndex(scan.structure);
1844
2050
  const { code, structure } = scan;
1845
2051
  const sourceLines = source.split(/\r?\n/);
1846
2052
  // Tokens arrive in source order, so one running count serves every finding.
@@ -1860,7 +2066,7 @@ function _fieldInBorderedRowRules(source) {
1860
2066
  snippet: (sourceLines[n - 1] || "").trim().slice(0, 200),
1861
2067
  });
1862
2068
  };
1863
- const open = [];
2069
+ const open = _openTagStack();
1864
2070
  _JSX_TOKEN_RE.lastIndex = 0;
1865
2071
  let token;
1866
2072
  while ((token = _JSX_TOKEN_RE.exec(structure))) {
@@ -1873,15 +2079,14 @@ function _fieldInBorderedRowRules(source) {
1873
2079
  continue;
1874
2080
  }
1875
2081
  if (closing) {
1876
- const at = open.map((tag) => tag && tag.name).lastIndexOf(name);
1877
- if (at >= 0) open.length = at;
2082
+ open.close(name);
1878
2083
  continue;
1879
2084
  }
1880
- const end = _tagEnd(structure, token.index);
2085
+ const end = scan.index.tagEnd(token.index);
1881
2086
  if (end === -1) break;
1882
2087
  const tag = { name, attrs: [token.index + token[0].length, end], borderedRow: undefined };
1883
2088
  if (_FIELD_TAG_NAMES.includes(name)) {
1884
- const parent = open[open.length - 1];
2089
+ const parent = open.top();
1885
2090
  if (parent && parent.borderedRow === undefined) parent.borderedRow = _isBorderedRow(scan, parent);
1886
2091
  if (parent && parent.borderedRow) {
1887
2092
  report(
@@ -1935,27 +2140,42 @@ const _WIDTH_HOLDER_RE =
1935
2140
  const _LAYOUT_FEED_WINDOW = 80;
1936
2141
 
1937
2142
  function _measuredWidthNames(code) {
2143
+ const fedByLayout = _layoutFedCallees(code);
1938
2144
  const names = [];
1939
2145
  _WIDTH_HOLDER_RE.lastIndex = 0;
1940
2146
  let held;
1941
2147
  while ((held = _WIDTH_HOLDER_RE.exec(code))) {
1942
2148
  const [, value, setter, hook] = held;
1943
- if (hook === "useContainerWidth") {
2149
+ if (hook === "useContainerWidth" || fedByLayout.has(setter)) {
1944
2150
  names.push(value);
1945
- continue;
1946
2151
  }
1947
- const fedByLayout = new RegExp(
1948
- `\\b${setter}\\s*\\([\\s\\S]{0,${_LAYOUT_FEED_WINDOW}}?\\blayout\\.width\\b`,
1949
- );
1950
- if (fedByLayout.test(code)) names.push(value);
1951
2152
  }
1952
2153
  return names;
1953
2154
  }
1954
2155
 
1955
- function _sizesFromMeasuredWidth(code, names) {
1956
- return names.some((name) =>
1957
- new RegExp(`\\b${name}\\s*[-/*]|[-/*]\\s*\\b${name}\\b`).test(code),
2156
+ // Every callee with a layout width among its first arguments, in one pass:
2157
+ // a regex per declared setter rescanned the source per declaration (sc-8204).
2158
+ function _layoutFedCallees(code) {
2159
+ const fed = new Set();
2160
+ const call = /\b(\w+)\s*\(/g;
2161
+ const feed = new RegExp(
2162
+ `[\\s\\S]{0,${_LAYOUT_FEED_WINDOW}}?\\blayout\\.width\\b`,
2163
+ "y",
1958
2164
  );
2165
+ let callee;
2166
+ while ((callee = call.exec(code))) {
2167
+ feed.lastIndex = call.lastIndex;
2168
+ if (feed.test(code)) fed.add(callee[1]);
2169
+ }
2170
+ return fed;
2171
+ }
2172
+
2173
+ function _sizesFromMeasuredWidth(code, names) {
2174
+ if (names.length === 0) return false;
2175
+ const operands = new Set();
2176
+ for (const m of code.matchAll(/\b(\w+)\s*[-/*]/g)) operands.add(m[1]);
2177
+ for (const m of code.matchAll(/[-/*]\s*\b(\w+)\b/g)) operands.add(m[1]);
2178
+ return names.some((name) => operands.has(name));
1959
2179
  }
1960
2180
 
1961
2181
  function _measuredPaddingRules(source) {
@@ -1965,6 +2185,7 @@ function _measuredPaddingRules(source) {
1965
2185
  const measured = _measuredWidthNames(code);
1966
2186
  if (!_sizesFromMeasuredWidth(code, measured)) return [];
1967
2187
  const sourceLines = source.split(/\r?\n/);
2188
+ const lineAt = _lineCounter(code);
1968
2189
  const findings = [];
1969
2190
  _JSX_TAG_RE.lastIndex = 0;
1970
2191
  let tag;
@@ -1977,9 +2198,7 @@ function _measuredPaddingRules(source) {
1977
2198
  if (!padded) continue;
1978
2199
  // Report the padding, not the tag: on a multi-line open tag the style line
1979
2200
  // is the actionable one.
1980
- const line = code
1981
- .slice(0, tag.index + padded.index)
1982
- .split(/\r?\n/).length;
2201
+ const line = lineAt(tag.index + padded.index);
1983
2202
  findings.push({
1984
2203
  rule: "measured-width-ignores-padding",
1985
2204
  severity: "warning",
@@ -2139,6 +2358,17 @@ function _hardcodedDesignRules(source) {
2139
2358
  * map, an array of sources, or one source string
2140
2359
  * @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
2141
2360
  */
2361
+ // Whether `\b<name>\b` matches `code`, from one pass over its words: a
2362
+ // regex per styleSchema field re-read the whole bundle per field (sc-8204).
2363
+ function _wordReader(code) {
2364
+ const words = new Set(code.match(/\w+/g));
2365
+ return (name) => {
2366
+ if (!name.includes("$")) return words.has(name);
2367
+ // Unescaped, `$` asserts end of input, so only the tail can match.
2368
+ return new RegExp(`\\b${name}\\b`).test(code.slice(-(name.length + 1)));
2369
+ };
2370
+ }
2371
+
2142
2372
  export function lintStyleWiring(manifest, files) {
2143
2373
  const empty = { ok: true, findings: [] };
2144
2374
  const schema = manifest && manifest.styleSchema;
@@ -2154,12 +2384,13 @@ export function lintStyleWiring(manifest, files) {
2154
2384
  ? _WHOLE_WIDGET_STYLE_FIELDS
2155
2385
  : new Set();
2156
2386
  const findings = [];
2387
+ const isRead = _wordReader(code);
2157
2388
  for (const field of Object.keys(schema)) {
2158
2389
  // A key that is not a bare identifier cannot be scanned by name; the
2159
2390
  // manifest validator rejects those anyway.
2160
2391
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
2161
2392
  if (appliedByHelper.has(field)) continue;
2162
- if (new RegExp(`\\b${field}\\b`).test(code)) continue;
2393
+ if (isRead(field)) continue;
2163
2394
  findings.push({
2164
2395
  rule: "style-field-unread",
2165
2396
  severity: "warning",
@@ -2263,49 +2494,92 @@ function _isAuthoredCopy(text) {
2263
2494
  return !_SEPARATOR_GLYPHS.has(text);
2264
2495
  }
2265
2496
 
2266
- // The `>` that CLOSES a JSX tag, skipping quoted attribute values and any `>`
2267
- // inside a `{…}` expression — `onPress={() => x}` carries one, and treating it
2268
- // as the tag end reports the handler body as copy.
2269
- function _tagEnd(code, from) {
2270
- let depth = 0;
2271
- let quote = null;
2272
- for (let i = from; i < code.length; i++) {
2273
- const ch = code[i];
2274
- if (quote) {
2275
- if (ch === "\\") i++;
2276
- else if (ch === quote) quote = null;
2277
- continue;
2278
- }
2279
- if (ch === '"' || ch === "'" || ch === "`") quote = ch;
2280
- else if (ch === "{") depth++;
2281
- else if (ch === "}") depth--;
2282
- else if (ch === ">" && depth <= 0) return i;
2283
- }
2284
- return -1;
2497
+ // Tag ends and brace spans over a STRING-BLANKED mask, where quotes are
2498
+ // already gone, so only brace depth decides. Answered from one index per
2499
+ // source: a scan per match was quadratic, and a file of bare `<Text`
2500
+ // wedged the API pod for a minute (sc-8204).
2501
+ function _jsxScanIndex(mask) {
2502
+ const depth = new Int32Array(mask.length + 1);
2503
+ for (let i = 0; i < mask.length; i++) {
2504
+ const ch = mask[i];
2505
+ depth[i + 1] = depth[i] + (ch === "{" ? 1 : ch === "}" ? -1 : 0);
2506
+ }
2507
+ let tagEnds = null;
2508
+ let braceCloses = null;
2509
+ let braceOpens = null;
2510
+ const closes = () => {
2511
+ if (!braceCloses) braceCloses = _nextAtOrBelow(mask, depth, "}");
2512
+ return braceCloses;
2513
+ };
2514
+ return {
2515
+ // The `>` that CLOSES a JSX tag, skipping any `>` inside a `{…}`
2516
+ // expression — `onPress={() => x}` carries one, and treating it as the
2517
+ // tag end reports the handler body as copy.
2518
+ tagEnd: (from) => {
2519
+ if (!tagEnds) tagEnds = _nextAtOrBelow(mask, depth, ">");
2520
+ return tagEnds[from];
2521
+ },
2522
+ // The `}` balancing the `{` at `from`, or -1.
2523
+ braceClose: (from) => (mask[from] === "{" ? closes()[from + 1] : -1),
2524
+ // The innermost `{…}` around offset `at`, as [start, end), or null.
2525
+ enclosingObject: (at) => {
2526
+ if (!braceOpens) braceOpens = _previousOpenAtOrBelow(mask, depth);
2527
+ const start = braceOpens[at];
2528
+ const end = closes()[at];
2529
+ return start === -1 || end === -1 ? null : [start, end + 1];
2530
+ },
2531
+ };
2285
2532
  }
2286
2533
 
2287
- // The balanced `{…}` starting at `from`, or null.
2288
- function _bracedSpan(code, from) {
2289
- if (code[from] !== "{") return null;
2290
- let depth = 0;
2291
- let quote = null;
2292
- for (let i = from; i < code.length; i++) {
2293
- const ch = code[i];
2294
- if (quote) {
2295
- if (ch === "\\") i++;
2296
- else if (ch === quote) quote = null;
2297
- continue;
2534
+ // For every offset i, the first j >= i holding `ch` with depth[j] at or
2535
+ // below depth[i], else -1. One right-to-left pass keeps the candidates on a
2536
+ // stack; a nearer one no deeper dominates, so depth rises toward the top
2537
+ // and each query is a binary search.
2538
+ function _nextAtOrBelow(mask, depth, ch) {
2539
+ const n = mask.length;
2540
+ const nearest = new Int32Array(n + 1).fill(-1);
2541
+ const stack = new Int32Array(n);
2542
+ let top = 0;
2543
+ for (let i = n - 1; i >= 0; i--) {
2544
+ if (mask[i] === ch) {
2545
+ while (top > 0 && depth[stack[top - 1]] >= depth[i]) top--;
2546
+ stack[top++] = i;
2298
2547
  }
2299
- if (ch === '"' || ch === "'" || ch === "`") quote = ch;
2300
- else if (ch === "{") depth++;
2301
- else if (ch === "}") {
2302
- depth--;
2303
- if (depth === 0) return code.slice(from + 1, i);
2548
+ nearest[i] = _topmostAtOrBelow(stack, top, depth, 0, depth[i]);
2549
+ }
2550
+ return nearest;
2551
+ }
2552
+
2553
+ // The mirror for opening braces: for every offset k, the last `{` at i < k
2554
+ // whose inside, depth[i + 1], is at or below depth[k], else -1.
2555
+ function _previousOpenAtOrBelow(mask, depth) {
2556
+ const n = mask.length;
2557
+ const nearest = new Int32Array(n + 1).fill(-1);
2558
+ const stack = new Int32Array(n);
2559
+ let top = 0;
2560
+ for (let k = 0; k <= n; k++) {
2561
+ nearest[k] = _topmostAtOrBelow(stack, top, depth, 1, depth[k]);
2562
+ if (k < n && mask[k] === "{") {
2563
+ while (top > 0 && depth[stack[top - 1] + 1] >= depth[k + 1]) top--;
2564
+ stack[top++] = k;
2304
2565
  }
2305
2566
  }
2306
- return null;
2567
+ return nearest;
2307
2568
  }
2308
2569
 
2570
+ function _topmostAtOrBelow(stack, top, depth, shift, limit) {
2571
+ let lo = 0;
2572
+ let hi = top - 1;
2573
+ let found = -1;
2574
+ while (lo <= hi) {
2575
+ const mid = (lo + hi) >> 1;
2576
+ if (depth[stack[mid] + shift] <= limit) {
2577
+ found = stack[mid];
2578
+ lo = mid + 1;
2579
+ } else hi = mid - 1;
2580
+ }
2581
+ return found;
2582
+ }
2309
2583
  // The non-escape branch excludes `\` so the two branches can never both
2310
2584
  // match the same character. With a bare `.`, escape pairs before a quote
2311
2585
  // the anchor then rejects backtrack exponentially — 60 characters of an
@@ -2314,8 +2588,7 @@ function _bracedSpan(code, from) {
2314
2588
  //
2315
2589
  // It excludes the line terminators too, because a character class — unlike
2316
2590
  // `.` — matches them, which would widen the literal past its own line.
2317
- // An odd trailing backslash run therefore no longer matches; `_bracedSpan`
2318
- // shares the escape rule and never yields one.
2591
+ // An odd trailing backslash run therefore no longer matches.
2319
2592
  const _TEXT_LITERAL_CHILD_RE =
2320
2593
  /^(["'`])((?:\\.|(?!\1)[^\\\r\n\u2028\u2029])*)\1$/;
2321
2594
 
@@ -2331,16 +2604,23 @@ function _textLiteralOccurrences(codes, masks) {
2331
2604
  // not read as markup. Both masks preserve offsets, so the child is read
2332
2605
  // back from the kept-strings source at the very same index.
2333
2606
  const mask = masks[f];
2607
+ const index = _jsxScanIndex(mask);
2608
+ const lineAt = _lineCounter(code);
2609
+ // Unclosed tags share the next closing `>`, and so share its child: read
2610
+ // each child once, or `<Text <Text … >` re-reads it per tag.
2611
+ const readEnds = new Set();
2334
2612
  const re = /<Text\b/g;
2335
2613
  let m;
2336
2614
  while ((m = re.exec(mask)) !== null) {
2337
- const end = _tagEnd(mask, m.index);
2338
- if (end === -1) continue;
2615
+ const end = index.tagEnd(m.index);
2616
+ if (end === -1 || readEnds.has(end)) continue;
2617
+ readEnds.add(end);
2339
2618
  let i = end + 1;
2340
2619
  while (i < code.length && /\s/.test(code[i])) i++;
2341
2620
  let text = null;
2342
2621
  if (code[i] === "{") {
2343
- const inner = _bracedSpan(code, i);
2622
+ const close = index.braceClose(i);
2623
+ const inner = close === -1 ? null : code.slice(i + 1, close);
2344
2624
  const lit = inner && inner.trim().match(_TEXT_LITERAL_CHILD_RE);
2345
2625
  // A template carrying `${…}` interpolates a value, so it is not a literal.
2346
2626
  if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
@@ -2352,7 +2632,7 @@ function _textLiteralOccurrences(codes, masks) {
2352
2632
  text = text.trim();
2353
2633
  if (!_isAuthoredCopy(text) || byText.has(text)) continue;
2354
2634
  // Split-impl carries the same copy twice; report the string, not the file.
2355
- byText.set(text, code.slice(0, i).split("\n").length);
2635
+ byText.set(text, lineAt(i));
2356
2636
  }
2357
2637
  }
2358
2638
  return [...byText.entries()].map(([text, line]) => ({ text, line }));