@zappie/check 0.1.3 → 0.2.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/index.js CHANGED
@@ -42,7 +42,7 @@ function distance(left, right) {
42
42
  }
43
43
  return row[right.length] ?? Infinity;
44
44
  }
45
- function unknownKeys(value, allowed, path13, issue) {
45
+ function unknownKeys(value, allowed, path14, issue) {
46
46
  for (const key of Object.keys(value)) {
47
47
  if (allowed.includes(key)) {
48
48
  continue;
@@ -50,7 +50,7 @@ function unknownKeys(value, allowed, path13, issue) {
50
50
  const closest = allowed.map((candidate) => ({ candidate, score: distance(key, candidate) })).sort((left, right) => left.score - right.score)[0];
51
51
  const hint = closest && closest.score <= 2 ? closest.candidate : void 0;
52
52
  issue({
53
- path: path13 ? `${path13}.${key}` : key,
53
+ path: path14 ? `${path14}.${key}` : key,
54
54
  code: "unknown_key",
55
55
  message: hint ? `Unknown field "${key}". Rename it to "${hint}".` : `Unknown field "${key}". Remove it from the configuration.`,
56
56
  severity: "error"
@@ -76,20 +76,20 @@ function hostname(value) {
76
76
  }
77
77
 
78
78
  // ../sdk/src/site-config/sections.ts
79
- function nested(value, path13, keys, issue, validate) {
79
+ function nested(value, path14, keys, issue, validate) {
80
80
  if (value === void 0) {
81
81
  return;
82
82
  }
83
83
  if (!record(value)) {
84
84
  issue({
85
- path: path13,
85
+ path: path14,
86
86
  code: "invalid_type",
87
- message: `Expected an object. Set ${path13} to an object or remove it.`,
87
+ message: `Expected an object. Set ${path14} to an object or remove it.`,
88
88
  severity: "error"
89
89
  });
90
90
  return;
91
91
  }
92
- unknownKeys(value, keys, path13, issue);
92
+ unknownKeys(value, keys, path14, issue);
93
93
  validate(value);
94
94
  }
95
95
  function budgets(value, issue, error) {
@@ -189,7 +189,7 @@ var rootKeys = [
189
189
  function validateConfig(input) {
190
190
  const issues = [];
191
191
  const issue = (value) => issues.push(value);
192
- const error = (path13, code, message5) => issue({ path: path13, code, message: message5, severity: "error" });
192
+ const error = (path14, code, message5) => issue({ path: path14, code, message: message5, severity: "error" });
193
193
  if (!record(input)) {
194
194
  return {
195
195
  valid: false,
@@ -284,12 +284,12 @@ function validateConfig(input) {
284
284
  issues
285
285
  };
286
286
  }
287
- function requiredSlug(value, path13, error) {
287
+ function requiredSlug(value, path14, error) {
288
288
  if (!nonblank(value)) {
289
289
  error(
290
- path13,
290
+ path14,
291
291
  value === void 0 ? "missing_required" : "invalid_type",
292
- `Expected a non-empty storefront slug. Set ${path13} to the slug used by createZappie().`
292
+ `Expected a non-empty storefront slug. Set ${path14} to the slug used by createZappie().`
293
293
  );
294
294
  }
295
295
  }
@@ -308,29 +308,29 @@ function theme(value, error) {
308
308
  );
309
309
  }
310
310
  }
311
- function stringList(value, path13, error, options = {}) {
311
+ function stringList(value, path14, error, options = {}) {
312
312
  if (value === void 0) {
313
313
  return void 0;
314
314
  }
315
315
  if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
316
316
  error(
317
- path13,
317
+ path14,
318
318
  "invalid_type",
319
- `Expected an array of strings. Set ${path13} to string values.`
319
+ `Expected an array of strings. Set ${path14} to string values.`
320
320
  );
321
321
  return void 0;
322
322
  }
323
323
  if (options.nonempty && value.length === 0) {
324
324
  error(
325
- path13,
325
+ path14,
326
326
  "invalid_format",
327
- `Expected at least one value. Add a value to ${path13} or remove the field.`
327
+ `Expected at least one value. Add a value to ${path14} or remove the field.`
328
328
  );
329
329
  }
330
330
  value.forEach((entry, index) => {
331
331
  if (options.slug && !nonblank(entry)) {
332
332
  error(
333
- `${path13}[${index}]`,
333
+ `${path14}[${index}]`,
334
334
  "invalid_format",
335
335
  "Expected a non-empty branch slug. Remove the empty value or use a branch slug."
336
336
  );
@@ -338,17 +338,17 @@ function stringList(value, path13, error, options = {}) {
338
338
  });
339
339
  if (options.unique && new Set(value).size !== value.length) {
340
340
  error(
341
- path13,
341
+ path14,
342
342
  "duplicate",
343
- `Expected unique values. Remove duplicates from ${path13}.`
343
+ `Expected unique values. Remove duplicates from ${path14}.`
344
344
  );
345
345
  }
346
346
  return value;
347
347
  }
348
348
  function paths(value, error) {
349
349
  const values = stringList(value, "prerender", error, { unique: true });
350
- values?.forEach((path13, index) => {
351
- if (!path13.startsWith("/")) {
350
+ values?.forEach((path14, index) => {
351
+ if (!path14.startsWith("/")) {
352
352
  error(
353
353
  `prerender[${index}]`,
354
354
  "invalid_format",
@@ -851,11 +851,11 @@ var record2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
851
851
  var includes = (values, key) => values.includes(key);
852
852
  function validateTheme(input) {
853
853
  const issues = [];
854
- const issue = (path13, code, message5, severity = "error") => issues.push({ path: path13, code, message: message5, severity });
855
- const keys = (obj, allowed, path13) => {
854
+ const issue = (path14, code, message5, severity = "error") => issues.push({ path: path14, code, message: message5, severity });
855
+ const keys = (obj, allowed, path14) => {
856
856
  for (const key of Object.keys(obj)) {
857
857
  if (!allowed.includes(key)) {
858
- issue(path13 ? `${path13}.${key}` : key, "invalid_theme", "Unknown field");
858
+ issue(path14 ? `${path14}.${key}` : key, "invalid_theme", "Unknown field");
859
859
  }
860
860
  }
861
861
  };
@@ -916,9 +916,9 @@ function validateTheme(input) {
916
916
  }
917
917
  }
918
918
  for (const [key, value] of Object.entries(values)) {
919
- const path13 = `${group}.${key}`;
919
+ const path14 = `${group}.${key}`;
920
920
  if (!includes(TOKEN_NAMES, key)) {
921
- issue(path13, "unknown_token", "Unknown semantic token");
921
+ issue(path14, "unknown_token", "Unknown semantic token");
922
922
  continue;
923
923
  }
924
924
  let code, valid = false;
@@ -936,7 +936,7 @@ function validateTheme(input) {
936
936
  valid = typeof value === "string" && safeValue(value) && (includes(DURATION_TOKENS, key) ? isDuration(value) : isEasing(value));
937
937
  }
938
938
  if (!valid) {
939
- issue(path13, code, "Invalid or unsafe token value");
939
+ issue(path14, code, "Invalid or unsafe token value");
940
940
  }
941
941
  }
942
942
  }
@@ -946,30 +946,30 @@ function validateTheme(input) {
946
946
  } else {
947
947
  const roles = /* @__PURE__ */ new Set();
948
948
  input.fonts.forEach((font, i) => {
949
- const path13 = `fonts[${i}]`;
949
+ const path14 = `fonts[${i}]`;
950
950
  if (!record2(font)) {
951
- issue(path13, "invalid_font", "Expected a font object");
951
+ issue(path14, "invalid_font", "Expected a font object");
952
952
  return;
953
953
  }
954
- keys(font, ["family", "source", "url", "weights", "role"], path13);
954
+ keys(font, ["family", "source", "url", "weights", "role"], path14);
955
955
  if (typeof font.family !== "string" || !isFontFamily(font.family) || font.family.includes(",")) {
956
956
  issue(
957
- `${path13}.family`,
957
+ `${path14}.family`,
958
958
  "invalid_font",
959
959
  "Expected one unquoted font family"
960
960
  );
961
961
  }
962
962
  if (typeof font.source !== "string" || !["google", "self", "system"].includes(font.source)) {
963
- issue(`${path13}.source`, "invalid_font", "Unknown font source");
963
+ issue(`${path14}.source`, "invalid_font", "Unknown font source");
964
964
  }
965
965
  if (typeof font.role !== "string" || !["display", "body", "mono"].includes(font.role) || roles.has(font.role)) {
966
- issue(`${path13}.role`, "invalid_font", "Expected a unique font role");
966
+ issue(`${path14}.role`, "invalid_font", "Expected a unique font role");
967
967
  }
968
968
  roles.add(String(font.role));
969
969
  if (font.source === "self" || font.url !== void 0) {
970
970
  if (typeof font.url !== "string" || !isAssetUrl(font.url, false) || !new URL(font.url).pathname.endsWith(".woff2")) {
971
971
  issue(
972
- `${path13}.url`,
972
+ `${path14}.url`,
973
973
  "invalid_font",
974
974
  "Expected an HTTPS .woff2 URL"
975
975
  );
@@ -979,7 +979,7 @@ function validateTheme(input) {
979
979
  (w) => typeof w !== "number" || !Number.isInteger(w) || w < 1 || w > 1e3
980
980
  ))) {
981
981
  issue(
982
- `${path13}.weights`,
982
+ `${path14}.weights`,
983
983
  "invalid_font",
984
984
  "Expected 1\u20139 integer weights between 1 and 1000"
985
985
  );
@@ -1556,10 +1556,784 @@ ${stderr}`.split(/\r?\n/).map((line) => line.trimEnd()).filter(Boolean).slice(-1
1556
1556
  return lines.length > 0 ? lines.join("\n") : "The build exited without output.";
1557
1557
  }
1558
1558
 
1559
+ // src/css-files.ts
1560
+ import { lstat, readFile as readFile8, readdir as readdir2 } from "fs/promises";
1561
+ import path8 from "path";
1562
+ var FILE_SIZE_CAP = 1024 * 1024;
1563
+ var BUILD_FILE_SIZE_CAP = 8 * 1024 * 1024;
1564
+ var ignoredDirectories = /* @__PURE__ */ new Set([
1565
+ "node_modules",
1566
+ "build",
1567
+ "dist",
1568
+ "coverage",
1569
+ "public",
1570
+ "vendor",
1571
+ "test",
1572
+ "tests",
1573
+ "e2e",
1574
+ "test-results",
1575
+ "playwright-report"
1576
+ ]);
1577
+ var codeExtension = /\.(?:[cm]?[jt]sx?|html|mdx)$/;
1578
+ var testFile = /\.(?:test|spec)\.[^.]+$/;
1579
+ async function readCssInputs(cwd) {
1580
+ const source = [];
1581
+ await collect2(cwd, source, (name) => {
1582
+ return !name.startsWith(".") && !ignoredDirectories.has(name);
1583
+ });
1584
+ const built = [];
1585
+ await collect2(path8.join(cwd, "build/client"), built, () => true);
1586
+ const read = (files, cap) => texts(cwd, files, cap);
1587
+ return {
1588
+ sourceCss: await read(
1589
+ source.filter((file) => file.endsWith(".css")),
1590
+ FILE_SIZE_CAP
1591
+ ),
1592
+ sourceCode: await read(
1593
+ source.filter(
1594
+ (file) => codeExtension.test(file) && !testFile.test(file) && !/(?:^|[/\\])[^/\\]+\.config\.[^.]+$/.test(file)
1595
+ ),
1596
+ FILE_SIZE_CAP
1597
+ ),
1598
+ builtCss: await read(
1599
+ built.filter((file) => file.endsWith(".css")),
1600
+ BUILD_FILE_SIZE_CAP
1601
+ ),
1602
+ builtCode: await read(
1603
+ built.filter((file) => file.endsWith(".js")),
1604
+ BUILD_FILE_SIZE_CAP
1605
+ )
1606
+ };
1607
+ }
1608
+ async function texts(cwd, files, cap) {
1609
+ const result = [];
1610
+ for (const file of files.sort()) {
1611
+ const info = await lstat(file);
1612
+ if (!info.isFile() || info.size > cap) {
1613
+ continue;
1614
+ }
1615
+ result.push({
1616
+ file: relative(cwd, file),
1617
+ text: await readFile8(file, "utf8")
1618
+ });
1619
+ }
1620
+ return result;
1621
+ }
1622
+ async function collect2(directory, files, enter) {
1623
+ let entries;
1624
+ try {
1625
+ entries = await readdir2(directory, { withFileTypes: true });
1626
+ } catch (error) {
1627
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1628
+ return;
1629
+ }
1630
+ throw error;
1631
+ }
1632
+ for (const entry of entries) {
1633
+ if (entry.isSymbolicLink()) {
1634
+ continue;
1635
+ }
1636
+ const file = path8.join(directory, entry.name);
1637
+ if (entry.isDirectory()) {
1638
+ if (enter(entry.name)) {
1639
+ await collect2(file, files, enter);
1640
+ }
1641
+ } else if (entry.isFile()) {
1642
+ files.push(file);
1643
+ }
1644
+ }
1645
+ }
1646
+
1647
+ // src/css-rules.ts
1648
+ import postcss from "postcss";
1649
+ var CSS_ERRORS = /* @__PURE__ */ new Set([
1650
+ "css/important",
1651
+ "css/class-patch"
1652
+ ]);
1653
+ function analyseCss(inputs) {
1654
+ const source = parseAll(inputs.sourceCss);
1655
+ const built = parseAll(inputs.builtCss);
1656
+ return [
1657
+ ...source.problems,
1658
+ ...built.problems,
1659
+ ...important(source.parsed, inputs.sourceCode),
1660
+ ...duplicateKeyframes(source.parsed, built.parsed),
1661
+ ...duplicateSelectors(source.parsed),
1662
+ ...arbitraryTokens(source.parsed, inputs.sourceCode),
1663
+ ...unusedCustomProperties(source.parsed, built.parsed, inputs),
1664
+ ...reducedMotion(source.parsed),
1665
+ ...classPatches(source.parsed),
1666
+ ...literalTokens(source.parsed)
1667
+ ];
1668
+ }
1669
+ function parseAll(files) {
1670
+ const parsed = [];
1671
+ const problems = [];
1672
+ for (const entry of files) {
1673
+ try {
1674
+ parsed.push({
1675
+ ...entry,
1676
+ root: postcss.parse(entry.text, { from: entry.file }),
1677
+ lines: entry.text.split(/\r?\n/)
1678
+ });
1679
+ } catch (error) {
1680
+ const line = error && typeof error === "object" && "line" in error ? Number(error.line) : void 0;
1681
+ problems.push(
1682
+ problem(
1683
+ `${entry.file} could not be read as CSS: ${error instanceof Error ? error.message : String(error)}`,
1684
+ "Fix the CSS syntax so zappie check can read the file.",
1685
+ {
1686
+ rule: "css/unreadable",
1687
+ file: entry.file,
1688
+ ...line && line > 0 ? { line } : {}
1689
+ }
1690
+ )
1691
+ );
1692
+ }
1693
+ }
1694
+ return { parsed, problems };
1695
+ }
1696
+ var allowImportant = /zappie-allow-important:\s*\S/;
1697
+ function important(css, code) {
1698
+ const problems = [];
1699
+ const allowed = (lines, line) => line !== void 0 && allowImportant.test(lines[line - 1] ?? "");
1700
+ for (const entry of css) {
1701
+ entry.root.walkDecls((decl) => {
1702
+ const line = decl.source?.start?.line;
1703
+ if (decl.important && !allowed(entry.lines, line) && !insideMedia(decl.parent, "reduce")) {
1704
+ problems.push(
1705
+ importantProblem(
1706
+ `${decl.prop} is marked !important.`,
1707
+ entry.file,
1708
+ line
1709
+ )
1710
+ );
1711
+ }
1712
+ });
1713
+ entry.root.walkAtRules("apply", (rule) => {
1714
+ const line = rule.source?.start?.line;
1715
+ const flagged = rule.params.split(/\s+/).filter((token) => importantUtility(token));
1716
+ if ((flagged.length > 0 || /!important\b/.test(rule.params)) && !allowed(entry.lines, line)) {
1717
+ problems.push(
1718
+ importantProblem(
1719
+ `@apply ${rule.params} forces !important.`,
1720
+ entry.file,
1721
+ line
1722
+ )
1723
+ );
1724
+ }
1725
+ });
1726
+ }
1727
+ for (const entry of code) {
1728
+ const lines = entry.text.split(/\r?\n/);
1729
+ lines.forEach((text, index) => {
1730
+ if (allowImportant.test(text)) {
1731
+ return;
1732
+ }
1733
+ for (const list of classLists(text)) {
1734
+ for (const token of list.split(/\s+/)) {
1735
+ if (importantUtility(token)) {
1736
+ problems.push(
1737
+ importantProblem(
1738
+ `The utility ${token} forces !important.`,
1739
+ entry.file,
1740
+ index + 1
1741
+ )
1742
+ );
1743
+ }
1744
+ }
1745
+ }
1746
+ });
1747
+ }
1748
+ return problems;
1749
+ }
1750
+ function importantProblem(message5, file, line) {
1751
+ return problem(
1752
+ message5,
1753
+ "Raise the selector's specificity or reorder the rules instead. For a reviewed case, add /* zappie-allow-important: <reason> */ on the same line.",
1754
+ { rule: "css/important", file, ...line ? { line } : {} }
1755
+ );
1756
+ }
1757
+ var utilityToken = /^!?-?[a-z0-9@[\]_\-:/.%()#,'=&>*~+!]*[a-z0-9\])%]!?$/;
1758
+ function importantUtility(token) {
1759
+ const utility = withoutVariants(token).utility;
1760
+ if (utility.length < 3) {
1761
+ return false;
1762
+ }
1763
+ if (/^!-?[a-z[]/.test(utility)) {
1764
+ return true;
1765
+ }
1766
+ return /[a-z0-9\])%]!$/.test(utility) && /[-[]/.test(utility);
1767
+ }
1768
+ function classLists(line) {
1769
+ const lists = [];
1770
+ for (const match of line.matchAll(/(["'`])([^"'`]*?)\1/g)) {
1771
+ const value = match[2]?.trim() ?? "";
1772
+ if (!value) {
1773
+ continue;
1774
+ }
1775
+ const tokens = value.split(/\s+/);
1776
+ if (tokens.every((token) => utilityToken.test(token)) && tokens.some((token) => token.includes("-"))) {
1777
+ lists.push(value);
1778
+ }
1779
+ }
1780
+ return lists;
1781
+ }
1782
+ function withoutVariants(token) {
1783
+ let depth = 0;
1784
+ let cut = 0;
1785
+ for (let index = 0; index < token.length; index++) {
1786
+ const character = token[index];
1787
+ if (character === "[" || character === "(") {
1788
+ depth++;
1789
+ } else if (character === "]" || character === ")") {
1790
+ depth--;
1791
+ } else if (character === ":" && depth === 0) {
1792
+ cut = index + 1;
1793
+ }
1794
+ }
1795
+ return { variants: token.slice(0, cut), utility: token.slice(cut) };
1796
+ }
1797
+ function keyframesIn(files) {
1798
+ const found = [];
1799
+ for (const entry of files) {
1800
+ entry.root.walkAtRules(/keyframes$/i, (rule) => {
1801
+ const line = rule.source?.start?.line;
1802
+ found.push({
1803
+ name: rule.params.trim(),
1804
+ vendor: rule.name.toLowerCase().replace(/keyframes$/, ""),
1805
+ body: framesBody(rule),
1806
+ file: entry.file,
1807
+ ...line ? { line } : {}
1808
+ });
1809
+ });
1810
+ }
1811
+ return found;
1812
+ }
1813
+ function framesBody(rule) {
1814
+ const frames = [];
1815
+ rule.each((node) => {
1816
+ if (node.type !== "rule") {
1817
+ return;
1818
+ }
1819
+ const declarations = [];
1820
+ node.walkDecls((decl) => {
1821
+ declarations.push(
1822
+ `${decl.prop.toLowerCase()}:${normaliseValue(decl.value)}${decl.important ? "!" : ""}`
1823
+ );
1824
+ });
1825
+ const selector = node.selectors.map(
1826
+ (value) => value.trim().toLowerCase().replace(/^from$/, "0%").replace(/^to$/, "100%")
1827
+ ).join(",");
1828
+ frames.push(`${selector}{${declarations.join(";")}}`);
1829
+ });
1830
+ return frames.join("");
1831
+ }
1832
+ function normaliseValue(value) {
1833
+ return value.toLowerCase().replace(/\s+/g, "").replace(/(^|[^\d.])0+(\.\d)/g, "$1$2").replace(/(\d)\.0+(?!\d)/g, "$1");
1834
+ }
1835
+ function duplicateKeyframes(source, built) {
1836
+ const problems = [];
1837
+ const reported = /* @__PURE__ */ new Set();
1838
+ const sourceFrames = keyframesIn(source);
1839
+ const builtFrames = keyframesIn(built);
1840
+ const fix = "Keep one @keyframes for each animation and refer to it by that name.";
1841
+ const byName = (frames) => {
1842
+ const groups = /* @__PURE__ */ new Map();
1843
+ for (const frame of frames) {
1844
+ const key = `${frame.vendor}${frame.name}`;
1845
+ groups.set(key, [...groups.get(key) ?? [], frame]);
1846
+ }
1847
+ return groups;
1848
+ };
1849
+ for (const [key, group] of byName(sourceFrames)) {
1850
+ if (group.length > 1) {
1851
+ reported.add(key);
1852
+ const [first, ...rest] = group;
1853
+ for (const frame of rest) {
1854
+ problems.push(
1855
+ problem(
1856
+ `@keyframes ${frame.name} is defined again (first in ${first?.file}${first?.line ? `:${first.line}` : ""}).`,
1857
+ fix,
1858
+ location("css/duplicate-keyframes", frame)
1859
+ )
1860
+ );
1861
+ }
1862
+ }
1863
+ }
1864
+ for (const [key, group] of byName(builtFrames)) {
1865
+ if (reported.has(key) || new Set(group.map(({ body }) => body)).size < 2) {
1866
+ continue;
1867
+ }
1868
+ reported.add(key);
1869
+ const first = group[0];
1870
+ problems.push(
1871
+ problem(
1872
+ `@keyframes ${first?.name} is defined more than once with different frames in the built CSS, so the last one wins.`,
1873
+ `${fix} Rename yours if it clashes with one from a library or Tailwind.`,
1874
+ location("css/duplicate-keyframes", first)
1875
+ )
1876
+ );
1877
+ }
1878
+ const byBody = /* @__PURE__ */ new Map();
1879
+ for (const frame of [...sourceFrames, ...builtFrames]) {
1880
+ if (!frame.body) {
1881
+ continue;
1882
+ }
1883
+ const key = `${frame.vendor}${frame.body}`;
1884
+ const group = byBody.get(key) ?? [];
1885
+ if (!group.some(({ name }) => name === frame.name)) {
1886
+ group.push(frame);
1887
+ }
1888
+ byBody.set(key, group);
1889
+ }
1890
+ for (const group of byBody.values()) {
1891
+ if (group.length < 2) {
1892
+ continue;
1893
+ }
1894
+ const names = group.map(({ name }) => name);
1895
+ const [first, ...rest] = group;
1896
+ problems.push(
1897
+ problem(
1898
+ `@keyframes ${names.join(", ")} have the same frames under different names.`,
1899
+ `${fix} Use ${first?.name} for all of them.`,
1900
+ location("css/duplicate-keyframes", rest[0])
1901
+ )
1902
+ );
1903
+ }
1904
+ return problems;
1905
+ }
1906
+ function location(rule, entry) {
1907
+ return {
1908
+ rule,
1909
+ ...entry ? { file: entry.file } : {},
1910
+ ...entry?.line ? { line: entry.line } : {}
1911
+ };
1912
+ }
1913
+ function contextOf(node) {
1914
+ const parts = [];
1915
+ for (let parent = node; parent && parent.type !== "root"; parent = parent.parent) {
1916
+ if (parent.type === "atrule") {
1917
+ const rule = parent;
1918
+ parts.unshift(`@${rule.name} ${rule.params.replace(/\s+/g, " ").trim()}`);
1919
+ } else if (parent.type === "rule") {
1920
+ parts.unshift(
1921
+ parent.selectors.map(normaliseSelector).join(",")
1922
+ );
1923
+ }
1924
+ }
1925
+ return parts.join(" > ");
1926
+ }
1927
+ var normaliseSelector = (selector) => selector.replace(/\s+/g, " ").replace(/\s*([>+~,])\s*/g, "$1").trim();
1928
+ function duplicateSelectors(source) {
1929
+ const seen = /* @__PURE__ */ new Map();
1930
+ const problems = [];
1931
+ for (const entry of source) {
1932
+ entry.root.walkRules((rule) => {
1933
+ if (rule.parent?.type === "atrule" && /keyframes$/i.test(rule.parent.name)) {
1934
+ return;
1935
+ }
1936
+ const context = contextOf(rule.parent);
1937
+ const line = rule.source?.start?.line;
1938
+ const selectors = [...new Set(rule.selectors.map(normaliseSelector))];
1939
+ const keys = selectors.length === 1 ? selectors : [[...selectors].sort().join(",")];
1940
+ for (const selector of keys) {
1941
+ const key = `${context}\0${selector}`;
1942
+ const first = seen.get(key);
1943
+ if (!first) {
1944
+ seen.set(key, { file: entry.file, ...line ? { line } : {} });
1945
+ continue;
1946
+ }
1947
+ problems.push(
1948
+ problem(
1949
+ `${selector}${context ? ` (inside ${context})` : ""} is already styled in ${first.file}${first.line ? `:${first.line}` : ""}.`,
1950
+ "Merge the declarations into one rule so the cascade order cannot change the result.",
1951
+ location("css/duplicate-selector", {
1952
+ file: entry.file,
1953
+ ...line ? { line } : {}
1954
+ })
1955
+ )
1956
+ );
1957
+ }
1958
+ });
1959
+ }
1960
+ return problems;
1961
+ }
1962
+ var namespaces = [
1963
+ ["font-weight", ["font"]],
1964
+ ["inset-shadow", ["inset-shadow"]],
1965
+ ["drop-shadow", ["drop-shadow"]],
1966
+ ["text-shadow", ["text-shadow"]],
1967
+ [
1968
+ "color",
1969
+ [
1970
+ "text",
1971
+ "bg",
1972
+ "border",
1973
+ "border-x",
1974
+ "border-y",
1975
+ "border-t",
1976
+ "border-r",
1977
+ "border-b",
1978
+ "border-l",
1979
+ "border-s",
1980
+ "border-e",
1981
+ "ring",
1982
+ "ring-offset",
1983
+ "outline",
1984
+ "fill",
1985
+ "stroke",
1986
+ "decoration",
1987
+ "accent",
1988
+ "caret",
1989
+ "divide",
1990
+ "placeholder",
1991
+ "from",
1992
+ "via",
1993
+ "to",
1994
+ "shadow",
1995
+ "inset-shadow",
1996
+ "inset-ring"
1997
+ ]
1998
+ ],
1999
+ [
2000
+ "radius",
2001
+ [
2002
+ "rounded",
2003
+ "rounded-t",
2004
+ "rounded-r",
2005
+ "rounded-b",
2006
+ "rounded-l",
2007
+ "rounded-tl",
2008
+ "rounded-tr",
2009
+ "rounded-br",
2010
+ "rounded-bl",
2011
+ "rounded-s",
2012
+ "rounded-e",
2013
+ "rounded-ss",
2014
+ "rounded-se",
2015
+ "rounded-es",
2016
+ "rounded-ee"
2017
+ ]
2018
+ ],
2019
+ ["font", ["font"]],
2020
+ ["text", ["text"]],
2021
+ ["tracking", ["tracking"]],
2022
+ ["leading", ["leading"]],
2023
+ ["shadow", ["shadow"]],
2024
+ ["blur", ["blur"]],
2025
+ ["ease", ["ease"]],
2026
+ ["animate", ["animate"]],
2027
+ ["container", ["max-w", "min-w", "w"]]
2028
+ ];
2029
+ function themeTokens(source) {
2030
+ const tokens = [];
2031
+ for (const entry of source) {
2032
+ entry.root.walkAtRules("theme", (rule) => {
2033
+ rule.walkDecls(/^--/, (decl) => {
2034
+ for (const [namespace, utilities] of namespaces) {
2035
+ const prefix = `--${namespace}-`;
2036
+ if (decl.prop.startsWith(prefix) && decl.prop.length > prefix.length) {
2037
+ tokens.push({
2038
+ variable: decl.prop,
2039
+ value: compact(decl.value),
2040
+ utilities,
2041
+ name: decl.prop.slice(prefix.length)
2042
+ });
2043
+ break;
2044
+ }
2045
+ }
2046
+ });
2047
+ });
2048
+ }
2049
+ return tokens;
2050
+ }
2051
+ var compact = (value) => value.toLowerCase().replace(/_/g, " ").replace(/\s+/g, "");
2052
+ var arbitraryUtility = /(?<![\w-])((?:[^\s"'`<>{}]*:)?)(!?)(-?)([a-z][a-z-]*?)-(\[([^\]\s"'`]+)\]|\((--[a-z0-9_-]+)\))((?:\/[\w.%]+)?!?)(?![\w-])/g;
2053
+ function arbitraryTokens(source, code) {
2054
+ const tokens = themeTokens(source);
2055
+ if (tokens.length === 0) {
2056
+ return [];
2057
+ }
2058
+ const problems = [];
2059
+ const scan = (text, file, lineOffset = 0) => {
2060
+ text.split(/\r?\n/).forEach((line, index) => {
2061
+ for (const match of line.matchAll(arbitraryUtility)) {
2062
+ const [
2063
+ whole,
2064
+ variants = "",
2065
+ bang = "",
2066
+ negative = "",
2067
+ utility = "",
2068
+ ,
2069
+ bracket,
2070
+ parenthesis,
2071
+ modifier = ""
2072
+ ] = match;
2073
+ const raw = bracket ?? `var(${parenthesis})`;
2074
+ const value = compact(raw.replace(/^[a-z-]+:(?!\/)/, ""));
2075
+ const token = tokens.find(
2076
+ (candidate) => candidate.utilities.includes(utility) && (value === candidate.value || value === `var(${candidate.variable})`)
2077
+ );
2078
+ if (!token) {
2079
+ continue;
2080
+ }
2081
+ const replacement = `${variants}${bang}${negative}${utility}-${token.name}${modifier}`;
2082
+ problems.push(
2083
+ problem(
2084
+ `${whole} restates the theme token ${token.variable}.`,
2085
+ `Use ${replacement}.`,
2086
+ { rule: "css/arbitrary-token", file, line: lineOffset + index + 1 }
2087
+ )
2088
+ );
2089
+ }
2090
+ });
2091
+ };
2092
+ for (const entry of code) {
2093
+ scan(entry.text, entry.file);
2094
+ }
2095
+ for (const entry of source) {
2096
+ entry.root.walkAtRules("apply", (rule) => {
2097
+ scan(rule.params, entry.file, (rule.source?.start?.line ?? 1) - 1);
2098
+ });
2099
+ }
2100
+ return problems;
2101
+ }
2102
+ function unusedCustomProperties(source, built, inputs) {
2103
+ const definitions = /* @__PURE__ */ new Map();
2104
+ for (const entry of source) {
2105
+ entry.root.walkDecls(/^--/, (decl) => {
2106
+ if (insideTheme(decl.parent) || definitions.has(decl.prop)) {
2107
+ return;
2108
+ }
2109
+ const line = decl.source?.start?.line;
2110
+ definitions.set(decl.prop, {
2111
+ file: entry.file,
2112
+ ...line ? { line } : {}
2113
+ });
2114
+ });
2115
+ }
2116
+ if (definitions.size === 0) {
2117
+ return [];
2118
+ }
2119
+ const read = /* @__PURE__ */ new Set();
2120
+ const cssReads = /var\(\s*(--[\w-]+)/g;
2121
+ for (const entry of [...source, ...built]) {
2122
+ for (const match of entry.text.matchAll(cssReads)) {
2123
+ read.add(match[1] ?? "");
2124
+ }
2125
+ }
2126
+ const scriptMentions = /(?<![\w-])(--[a-zA-Z][\w-]*)/g;
2127
+ for (const entry of [...inputs.sourceCode, ...inputs.builtCode]) {
2128
+ for (const match of entry.text.matchAll(scriptMentions)) {
2129
+ read.add(match[1] ?? "");
2130
+ }
2131
+ }
2132
+ const problems = [];
2133
+ for (const [name, where] of definitions) {
2134
+ if (!read.has(name)) {
2135
+ problems.push(
2136
+ problem(
2137
+ `${name} is defined but nothing reads it.`,
2138
+ "Remove the custom property, or read it with var().",
2139
+ location("css/unused-custom-property", where)
2140
+ )
2141
+ );
2142
+ }
2143
+ }
2144
+ return problems;
2145
+ }
2146
+ function insideTheme(node) {
2147
+ for (let parent = node; parent; parent = parent.parent) {
2148
+ if (parent.type === "atrule" && parent.name === "theme") {
2149
+ return true;
2150
+ }
2151
+ }
2152
+ return false;
2153
+ }
2154
+ var motionProperties = /* @__PURE__ */ new Set([
2155
+ "animation",
2156
+ "animation-name",
2157
+ "transition",
2158
+ "transition-property"
2159
+ ]);
2160
+ var stillValue = /^(?:none|0m?s|initial|unset|inherit)$/i;
2161
+ var reducedMotionQuery = (rule, preference) => rule.name === "media" && new RegExp(`prefers-reduced-motion\\s*:\\s*${preference}`, "i").test(
2162
+ rule.params
2163
+ );
2164
+ function insideMedia(node, preference) {
2165
+ for (let parent = node; parent; parent = parent.parent) {
2166
+ if (parent.type === "atrule" && reducedMotionQuery(parent, preference)) {
2167
+ return true;
2168
+ }
2169
+ }
2170
+ return false;
2171
+ }
2172
+ var globalSelector = /^(?:\*|html|:root|body|\*::?before|\*::?after|::?before|::?after)$/;
2173
+ function reducedMotion(source) {
2174
+ let global = false;
2175
+ const stilled = /* @__PURE__ */ new Set();
2176
+ for (const entry of source) {
2177
+ entry.root.walkAtRules("media", (media) => {
2178
+ if (!reducedMotionQuery(media, "reduce")) {
2179
+ return;
2180
+ }
2181
+ media.walkRules((rule) => {
2182
+ let touchesMotion = false;
2183
+ rule.walkDecls((decl) => {
2184
+ if (/^(?:animation|transition)/.test(decl.prop)) {
2185
+ touchesMotion = true;
2186
+ }
2187
+ });
2188
+ if (!touchesMotion) {
2189
+ return;
2190
+ }
2191
+ for (const selector of rule.selectors.map(normaliseSelector)) {
2192
+ stilled.add(selector);
2193
+ if (globalSelector.test(selector)) {
2194
+ global = true;
2195
+ }
2196
+ }
2197
+ });
2198
+ });
2199
+ }
2200
+ if (global) {
2201
+ return [];
2202
+ }
2203
+ const problems = [];
2204
+ for (const entry of source) {
2205
+ entry.root.walkDecls((decl) => {
2206
+ if (!motionProperties.has(decl.prop) || stillValue.test(decl.value.trim())) {
2207
+ return;
2208
+ }
2209
+ const parent = decl.parent;
2210
+ if (insideMedia(parent, "reduce") || insideMedia(parent, "no-preference") || insideTheme(parent) || parent?.type !== "rule") {
2211
+ return;
2212
+ }
2213
+ const selectors = parent.selectors.map(
2214
+ normaliseSelector
2215
+ );
2216
+ if (selectors.every((selector) => stilled.has(selector))) {
2217
+ return;
2218
+ }
2219
+ const line = decl.source?.start?.line;
2220
+ problems.push(
2221
+ problem(
2222
+ `${selectors.join(", ")} sets ${decl.prop} with no prefers-reduced-motion path.`,
2223
+ "Wrap the motion in @media (prefers-reduced-motion: no-preference), or still it in a @media (prefers-reduced-motion: reduce) block.",
2224
+ location("css/reduced-motion", {
2225
+ file: entry.file,
2226
+ ...line ? { line } : {}
2227
+ })
2228
+ )
2229
+ );
2230
+ });
2231
+ }
2232
+ return problems;
2233
+ }
2234
+ var classAttribute = /\[\s*class\s*[*^$~|]?=\s*(?:"[^"]*"|'[^']*'|[^\]\s]+)\s*(?:[is]\s*)?\]/gi;
2235
+ function classPatches(source) {
2236
+ const problems = [];
2237
+ for (const entry of source) {
2238
+ entry.root.walkRules((rule) => {
2239
+ for (const match of rule.selector.matchAll(classAttribute)) {
2240
+ const line = rule.source?.start?.line;
2241
+ problems.push(
2242
+ problem(
2243
+ `${match[0]} patches elements by their class attribute.`,
2244
+ "Style the element through a theme token or its own class. Dark mode goes through tokens, not by matching utility classes.",
2245
+ location("css/class-patch", {
2246
+ file: entry.file,
2247
+ ...line ? { line } : {}
2248
+ })
2249
+ )
2250
+ );
2251
+ }
2252
+ });
2253
+ }
2254
+ return problems;
2255
+ }
2256
+ var colourLiteral = /#[0-9a-f]{3,8}\b|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^()]*\)/gi;
2257
+ var trivialValue = /^(?:0|none|inherit|initial|unset|auto|transparent|currentcolor|0px)$/;
2258
+ function literalTokens(source) {
2259
+ const tokens = /* @__PURE__ */ new Map();
2260
+ for (const entry of source) {
2261
+ entry.root.walkDecls(/^--/, (decl) => {
2262
+ const value = compact(decl.value);
2263
+ if (value.includes("var(") || trivialValue.test(value) || tokens.has(value)) {
2264
+ return;
2265
+ }
2266
+ const parent = decl.parent;
2267
+ const rootLevel = parent?.type === "rule" && parent.selectors.some(
2268
+ (selector) => /^(?::root|html)$/.test(selector.trim())
2269
+ );
2270
+ if (insideTheme(parent) || rootLevel) {
2271
+ tokens.set(value, decl.prop);
2272
+ }
2273
+ });
2274
+ }
2275
+ if (tokens.size === 0) {
2276
+ return [];
2277
+ }
2278
+ const problems = [];
2279
+ for (const entry of source) {
2280
+ entry.root.walkDecls((decl) => {
2281
+ if (decl.prop.startsWith("--") || insideTheme(decl.parent)) {
2282
+ return;
2283
+ }
2284
+ const prop = decl.prop.toLowerCase();
2285
+ const candidates = [];
2286
+ if (/radius$/.test(prop) || /shadow$/.test(prop)) {
2287
+ candidates.push(decl.value);
2288
+ }
2289
+ if (/(?:^|-)(?:color|background|border|outline|fill|stroke|shadow|decoration)/.test(
2290
+ prop
2291
+ ) || prop === "color" || prop === "fill" || prop === "stroke") {
2292
+ candidates.push(...decl.value.match(colourLiteral) ?? []);
2293
+ }
2294
+ for (const candidate of new Set(candidates.map(compact))) {
2295
+ const token = tokens.get(candidate);
2296
+ if (!token || trivialValue.test(candidate)) {
2297
+ continue;
2298
+ }
2299
+ const line = decl.source?.start?.line;
2300
+ problems.push(
2301
+ problem(
2302
+ `${decl.prop}: ${decl.value} restates the token ${token}.`,
2303
+ `Use var(${token}).`,
2304
+ location("css/literal-token", {
2305
+ file: entry.file,
2306
+ ...line ? { line } : {}
2307
+ })
2308
+ )
2309
+ );
2310
+ break;
2311
+ }
2312
+ });
2313
+ }
2314
+ return problems;
2315
+ }
2316
+
2317
+ // src/css.ts
2318
+ async function checkCss({
2319
+ cwd,
2320
+ signal
2321
+ }) {
2322
+ signal?.throwIfAborted();
2323
+ const inputs = await readCssInputs(cwd);
2324
+ if (inputs.sourceCss.length === 0 && inputs.builtCss.length === 0) {
2325
+ return skipped("css", "No source or built CSS files were found.");
2326
+ }
2327
+ const problems = analyseCss(inputs);
2328
+ signal?.throwIfAborted();
2329
+ const failed = problems.some(({ rule }) => CSS_ERRORS.has(rule));
2330
+ return step("css", failed ? "failed" : "passed", problems);
2331
+ }
2332
+
1559
2333
  // src/lint.ts
1560
2334
  import zappie from "eslint-plugin-zappie";
1561
2335
  import { createRequire } from "module";
1562
- import path8 from "path";
2336
+ import path9 from "path";
1563
2337
  import { pathToFileURL as pathToFileURL2 } from "url";
1564
2338
  async function checkLint({
1565
2339
  cwd,
@@ -1568,7 +2342,7 @@ async function checkLint({
1568
2342
  signal?.throwIfAborted();
1569
2343
  let ESLint;
1570
2344
  try {
1571
- const require2 = createRequire(path8.join(cwd, "package.json"));
2345
+ const require2 = createRequire(path9.join(cwd, "package.json"));
1572
2346
  const loadedEslint = await import(pathToFileURL2(require2.resolve("eslint")).href);
1573
2347
  ESLint = loadedEslint.ESLint;
1574
2348
  } catch (error) {
@@ -1634,7 +2408,9 @@ function recommendedRules() {
1634
2408
  Object.assign(result, config.rules ?? {});
1635
2409
  }
1636
2410
  return Object.fromEntries(
1637
- Object.entries(result).filter(([name]) => name.startsWith("zappie/"))
2411
+ Object.entries(result).filter(
2412
+ ([name]) => name.startsWith("zappie/") || name.startsWith("jsx-a11y/")
2413
+ )
1638
2414
  );
1639
2415
  }
1640
2416
  function level(value) {
@@ -1657,7 +2433,7 @@ async function sourceFile(cwd) {
1657
2433
  for (const candidate of candidates) {
1658
2434
  try {
1659
2435
  await import("fs/promises").then(
1660
- ({ access: access3 }) => access3(path8.join(cwd, candidate))
2436
+ ({ access: access3 }) => access3(path9.join(cwd, candidate))
1661
2437
  );
1662
2438
  return candidate;
1663
2439
  } catch {
@@ -1671,8 +2447,8 @@ function message2(error) {
1671
2447
  }
1672
2448
 
1673
2449
  // src/readiness.ts
1674
- import { readFile as readFile8 } from "fs/promises";
1675
- import path9 from "path";
2450
+ import { readFile as readFile9 } from "fs/promises";
2451
+ import path10 from "path";
1676
2452
  import { pathToFileURL as pathToFileURL3 } from "url";
1677
2453
  async function checkReadiness({
1678
2454
  cwd,
@@ -1690,9 +2466,9 @@ async function checkReadiness({
1690
2466
  )
1691
2467
  ]);
1692
2468
  }
1693
- const file = path9.join(cwd, "react-router.config.ts");
2469
+ const file = path10.join(cwd, "react-router.config.ts");
1694
2470
  try {
1695
- await readFile8(file, "utf8");
2471
+ await readFile9(file, "utf8");
1696
2472
  const version = Date.now();
1697
2473
  const config = await import(`${pathToFileURL3(file).href}?readiness=${version}`);
1698
2474
  if (config.default?.routeDiscovery?.mode === "initial") {
@@ -1723,8 +2499,8 @@ function message3(error) {
1723
2499
  }
1724
2500
 
1725
2501
  // src/secrets.ts
1726
- import { lstat, readFile as readFile9, readdir as readdir2 } from "fs/promises";
1727
- import path10 from "path";
2502
+ import { lstat as lstat2, readFile as readFile10, readdir as readdir3 } from "fs/promises";
2503
+ import path11 from "path";
1728
2504
 
1729
2505
  // src/secret-patterns.ts
1730
2506
  var FIX = "Move the value to a server environment variable and rotate it.";
@@ -1805,18 +2581,18 @@ var SECRET_FILE_SIZE_CAP = 1024 * 1024;
1805
2581
  async function scanSecrets(dir) {
1806
2582
  const findings = [];
1807
2583
  for (const file of await filesToScan(dir)) {
1808
- const info = await lstat(file);
2584
+ const info = await lstat2(file);
1809
2585
  if (!info.isFile() || info.size > SECRET_FILE_SIZE_CAP) {
1810
2586
  continue;
1811
2587
  }
1812
- const contents = await readFile9(file);
2588
+ const contents = await readFile10(file);
1813
2589
  if (contents.includes(0)) {
1814
2590
  continue;
1815
2591
  }
1816
2592
  const source = contents.toString("utf8");
1817
2593
  for (const match of secretMatches(
1818
2594
  source,
1819
- path10.basename(file).startsWith(".env")
2595
+ path11.basename(file).startsWith(".env")
1820
2596
  )) {
1821
2597
  findings.push({
1822
2598
  rule: match.rule,
@@ -1847,17 +2623,17 @@ async function checkSecrets({
1847
2623
  async function filesToScan(cwd) {
1848
2624
  const files = [];
1849
2625
  for (const directory of ["app", "public"]) {
1850
- await collect2(path10.join(cwd, directory), files);
2626
+ await collect3(path11.join(cwd, directory), files);
1851
2627
  }
1852
- await collect2(
1853
- path10.join(cwd, "build/client"),
2628
+ await collect3(
2629
+ path11.join(cwd, "build/client"),
1854
2630
  files,
1855
2631
  (file) => file.endsWith(".js")
1856
2632
  );
1857
2633
  try {
1858
- for (const entry of await readdir2(cwd, { withFileTypes: true })) {
2634
+ for (const entry of await readdir3(cwd, { withFileTypes: true })) {
1859
2635
  if (entry.isFile() && rootConfig(entry.name)) {
1860
- files.push(path10.join(cwd, entry.name));
2636
+ files.push(path11.join(cwd, entry.name));
1861
2637
  }
1862
2638
  }
1863
2639
  } catch {
@@ -1865,15 +2641,15 @@ async function filesToScan(cwd) {
1865
2641
  }
1866
2642
  return [...new Set(files)].sort();
1867
2643
  }
1868
- async function collect2(directory, files, accept = () => true) {
2644
+ async function collect3(directory, files, accept = () => true) {
1869
2645
  try {
1870
- for (const entry of await readdir2(directory, { withFileTypes: true })) {
2646
+ for (const entry of await readdir3(directory, { withFileTypes: true })) {
1871
2647
  if (entry.isSymbolicLink()) {
1872
2648
  continue;
1873
2649
  }
1874
- const file = path10.join(directory, entry.name);
2650
+ const file = path11.join(directory, entry.name);
1875
2651
  if (entry.isDirectory()) {
1876
- await collect2(file, files, accept);
2652
+ await collect3(file, files, accept);
1877
2653
  } else if (entry.isFile() && accept(file)) {
1878
2654
  files.push(file);
1879
2655
  }
@@ -1892,13 +2668,13 @@ function lineAt(source, index) {
1892
2668
  }
1893
2669
 
1894
2670
  // src/test-step.ts
1895
- import path11 from "path";
2671
+ import path12 from "path";
1896
2672
  async function checkTests({
1897
2673
  cwd,
1898
2674
  packageManager,
1899
2675
  signal
1900
2676
  }) {
1901
- const manifest = await json(path11.join(cwd, "package.json"));
2677
+ const manifest = await json(path12.join(cwd, "package.json"));
1902
2678
  if (!manifest.scripts?.test) {
1903
2679
  return skipped("test", "No test script is defined in package.json.");
1904
2680
  }
@@ -1921,11 +2697,11 @@ async function checkTests({
1921
2697
  }
1922
2698
 
1923
2699
  // src/types-step.ts
1924
- import path12 from "path";
2700
+ import path13 from "path";
1925
2701
  async function checkTypes(options) {
1926
2702
  const { cwd, packageManager, signal } = options;
1927
2703
  signal?.throwIfAborted();
1928
- const manifest = await json(path12.join(cwd, "package.json"));
2704
+ const manifest = await json(path13.join(cwd, "package.json"));
1929
2705
  if (manifest.dependencies?.["react-router"]) {
1930
2706
  const generated = execArgs(packageManager, "react-router", ["typegen"]);
1931
2707
  const result2 = await command(
@@ -1985,7 +2761,8 @@ var runners = [
1985
2761
  { name: "build", run: checkBuild },
1986
2762
  { name: "secrets", run: checkSecrets },
1987
2763
  { name: "readiness", run: checkReadiness },
1988
- { name: "budgets", run: checkBudgets }
2764
+ { name: "budgets", run: checkBudgets },
2765
+ { name: "css", run: checkCss }
1989
2766
  ];
1990
2767
  async function runCheck(options) {
1991
2768
  const steps = [];
@@ -2018,11 +2795,14 @@ function message4(error) {
2018
2795
  }
2019
2796
  export {
2020
2797
  BUILTIN_ALLOWLIST_SOURCE,
2798
+ CSS_ERRORS,
2021
2799
  SECRET_FILE_SIZE_CAP,
2800
+ analyseCss,
2022
2801
  checkAllowlist,
2023
2802
  checkBudgets,
2024
2803
  checkBuild,
2025
2804
  checkConfig,
2805
+ checkCss,
2026
2806
  checkLint,
2027
2807
  checkReadiness,
2028
2808
  checkSecrets,