@zappie/check 0.1.3 → 0.2.1

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, path15, 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: path15 ? `${path15}.${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, path15, 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: path15,
86
86
  code: "invalid_type",
87
- message: `Expected an object. Set ${path13} to an object or remove it.`,
87
+ message: `Expected an object. Set ${path15} 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, path15, issue);
93
93
  validate(value);
94
94
  }
95
95
  function budgets(value, issue, error) {
@@ -126,18 +126,18 @@ function budgets(value, issue, error) {
126
126
  `Expected a positive finite number. Set budgets.${key} to a number greater than zero.`
127
127
  );
128
128
  } else if (amount > DEFAULT_BUDGETS[key]) {
129
- const message5 = `The ${key} budget exceeds the ${DEFAULT_BUDGETS[key]} default.`;
129
+ const message6 = `The ${key} budget exceeds the ${DEFAULT_BUDGETS[key]} default.`;
130
130
  if (budget.override === void 0) {
131
131
  error(
132
132
  `budgets.${key}`,
133
133
  "budget_override",
134
- `${message5} Add budgets.override.reason to explain the increase.`
134
+ `${message6} Add budgets.override.reason to explain the increase.`
135
135
  );
136
136
  } else {
137
137
  issue({
138
138
  path: `budgets.${key}`,
139
139
  code: "budget_override",
140
- message: `${message5} Lower it to the default when the exception is no longer needed.`,
140
+ message: `${message6} Lower it to the default when the exception is no longer needed.`,
141
141
  severity: "warning"
142
142
  });
143
143
  }
@@ -181,6 +181,7 @@ var rootKeys = [
181
181
  "prerender",
182
182
  "menu",
183
183
  "packages",
184
+ "audit",
184
185
  "outbound",
185
186
  "budgets",
186
187
  "readiness",
@@ -189,7 +190,7 @@ var rootKeys = [
189
190
  function validateConfig(input) {
190
191
  const issues = [];
191
192
  const issue = (value) => issues.push(value);
192
- const error = (path13, code, message5) => issue({ path: path13, code, message: message5, severity: "error" });
193
+ const error = (path15, code, message6) => issue({ path: path15, code, message: message6, severity: "error" });
193
194
  if (!record(input)) {
194
195
  return {
195
196
  valid: false,
@@ -284,12 +285,12 @@ function validateConfig(input) {
284
285
  issues
285
286
  };
286
287
  }
287
- function requiredSlug(value, path13, error) {
288
+ function requiredSlug(value, path15, error) {
288
289
  if (!nonblank(value)) {
289
290
  error(
290
- path13,
291
+ path15,
291
292
  value === void 0 ? "missing_required" : "invalid_type",
292
- `Expected a non-empty storefront slug. Set ${path13} to the slug used by createZappie().`
293
+ `Expected a non-empty storefront slug. Set ${path15} to the slug used by createZappie().`
293
294
  );
294
295
  }
295
296
  }
@@ -308,29 +309,29 @@ function theme(value, error) {
308
309
  );
309
310
  }
310
311
  }
311
- function stringList(value, path13, error, options = {}) {
312
+ function stringList(value, path15, error, options = {}) {
312
313
  if (value === void 0) {
313
314
  return void 0;
314
315
  }
315
316
  if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
316
317
  error(
317
- path13,
318
+ path15,
318
319
  "invalid_type",
319
- `Expected an array of strings. Set ${path13} to string values.`
320
+ `Expected an array of strings. Set ${path15} to string values.`
320
321
  );
321
322
  return void 0;
322
323
  }
323
324
  if (options.nonempty && value.length === 0) {
324
325
  error(
325
- path13,
326
+ path15,
326
327
  "invalid_format",
327
- `Expected at least one value. Add a value to ${path13} or remove the field.`
328
+ `Expected at least one value. Add a value to ${path15} or remove the field.`
328
329
  );
329
330
  }
330
331
  value.forEach((entry, index) => {
331
332
  if (options.slug && !nonblank(entry)) {
332
333
  error(
333
- `${path13}[${index}]`,
334
+ `${path15}[${index}]`,
334
335
  "invalid_format",
335
336
  "Expected a non-empty branch slug. Remove the empty value or use a branch slug."
336
337
  );
@@ -338,17 +339,17 @@ function stringList(value, path13, error, options = {}) {
338
339
  });
339
340
  if (options.unique && new Set(value).size !== value.length) {
340
341
  error(
341
- path13,
342
+ path15,
342
343
  "duplicate",
343
- `Expected unique values. Remove duplicates from ${path13}.`
344
+ `Expected unique values. Remove duplicates from ${path15}.`
344
345
  );
345
346
  }
346
347
  return value;
347
348
  }
348
349
  function paths(value, error) {
349
350
  const values = stringList(value, "prerender", error, { unique: true });
350
- values?.forEach((path13, index) => {
351
- if (!path13.startsWith("/")) {
351
+ values?.forEach((path15, index) => {
352
+ if (!path15.startsWith("/")) {
352
353
  error(
353
354
  `prerender[${index}]`,
354
355
  "invalid_format",
@@ -851,11 +852,11 @@ var record2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
851
852
  var includes = (values, key) => values.includes(key);
852
853
  function validateTheme(input) {
853
854
  const issues = [];
854
- const issue = (path13, code, message5, severity = "error") => issues.push({ path: path13, code, message: message5, severity });
855
- const keys = (obj, allowed, path13) => {
855
+ const issue = (path15, code, message6, severity = "error") => issues.push({ path: path15, code, message: message6, severity });
856
+ const keys = (obj, allowed, path15) => {
856
857
  for (const key of Object.keys(obj)) {
857
858
  if (!allowed.includes(key)) {
858
- issue(path13 ? `${path13}.${key}` : key, "invalid_theme", "Unknown field");
859
+ issue(path15 ? `${path15}.${key}` : key, "invalid_theme", "Unknown field");
859
860
  }
860
861
  }
861
862
  };
@@ -916,9 +917,9 @@ function validateTheme(input) {
916
917
  }
917
918
  }
918
919
  for (const [key, value] of Object.entries(values)) {
919
- const path13 = `${group}.${key}`;
920
+ const path15 = `${group}.${key}`;
920
921
  if (!includes(TOKEN_NAMES, key)) {
921
- issue(path13, "unknown_token", "Unknown semantic token");
922
+ issue(path15, "unknown_token", "Unknown semantic token");
922
923
  continue;
923
924
  }
924
925
  let code, valid = false;
@@ -936,7 +937,7 @@ function validateTheme(input) {
936
937
  valid = typeof value === "string" && safeValue(value) && (includes(DURATION_TOKENS, key) ? isDuration(value) : isEasing(value));
937
938
  }
938
939
  if (!valid) {
939
- issue(path13, code, "Invalid or unsafe token value");
940
+ issue(path15, code, "Invalid or unsafe token value");
940
941
  }
941
942
  }
942
943
  }
@@ -946,30 +947,30 @@ function validateTheme(input) {
946
947
  } else {
947
948
  const roles = /* @__PURE__ */ new Set();
948
949
  input.fonts.forEach((font, i) => {
949
- const path13 = `fonts[${i}]`;
950
+ const path15 = `fonts[${i}]`;
950
951
  if (!record2(font)) {
951
- issue(path13, "invalid_font", "Expected a font object");
952
+ issue(path15, "invalid_font", "Expected a font object");
952
953
  return;
953
954
  }
954
- keys(font, ["family", "source", "url", "weights", "role"], path13);
955
+ keys(font, ["family", "source", "url", "weights", "role"], path15);
955
956
  if (typeof font.family !== "string" || !isFontFamily(font.family) || font.family.includes(",")) {
956
957
  issue(
957
- `${path13}.family`,
958
+ `${path15}.family`,
958
959
  "invalid_font",
959
960
  "Expected one unquoted font family"
960
961
  );
961
962
  }
962
963
  if (typeof font.source !== "string" || !["google", "self", "system"].includes(font.source)) {
963
- issue(`${path13}.source`, "invalid_font", "Unknown font source");
964
+ issue(`${path15}.source`, "invalid_font", "Unknown font source");
964
965
  }
965
966
  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");
967
+ issue(`${path15}.role`, "invalid_font", "Expected a unique font role");
967
968
  }
968
969
  roles.add(String(font.role));
969
970
  if (font.source === "self" || font.url !== void 0) {
970
971
  if (typeof font.url !== "string" || !isAssetUrl(font.url, false) || !new URL(font.url).pathname.endsWith(".woff2")) {
971
972
  issue(
972
- `${path13}.url`,
973
+ `${path15}.url`,
973
974
  "invalid_font",
974
975
  "Expected an HTTPS .woff2 URL"
975
976
  );
@@ -979,7 +980,7 @@ function validateTheme(input) {
979
980
  (w) => typeof w !== "number" || !Number.isInteger(w) || w < 1 || w > 1e3
980
981
  ))) {
981
982
  issue(
982
- `${path13}.weights`,
983
+ `${path15}.weights`,
983
984
  "invalid_font",
984
985
  "Expected 1\u20139 integer weights between 1 and 1000"
985
986
  );
@@ -1089,13 +1090,13 @@ function escape(value) {
1089
1090
  }
1090
1091
 
1091
1092
  // src/result.ts
1092
- function problem(message5, fix, details = {}) {
1093
- return { ...details, message: message5, fix };
1093
+ function problem(message6, fix, details = {}) {
1094
+ return { ...details, message: message6, fix };
1094
1095
  }
1095
1096
  function step(name, result, problems = []) {
1096
1097
  return { name, result, problems };
1097
1098
  }
1098
- var skipped = (name, message5) => step(name, "skipped", [problem(message5, null)]);
1099
+ var skipped = (name, message6) => step(name, "skipped", [problem(message6, null)]);
1099
1100
 
1100
1101
  // src/config.ts
1101
1102
  function supportsTypeScriptImport(version = process.versions.node) {
@@ -1318,22 +1319,500 @@ async function installedManifest(cwd, name) {
1318
1319
  }
1319
1320
  }
1320
1321
 
1322
+ // src/audit.ts
1323
+ import { access as access2, readFile as readFile4 } from "fs/promises";
1324
+ import path4 from "path";
1325
+
1326
+ // src/command.ts
1327
+ import { spawn } from "child_process";
1328
+ function command(executable, args, cwd, signal) {
1329
+ return new Promise((resolve, reject) => {
1330
+ const child = spawn(executable, args, {
1331
+ cwd,
1332
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
1333
+ stdio: ["ignore", "pipe", "pipe"],
1334
+ ...signal ? { signal } : {}
1335
+ });
1336
+ let stdout = "";
1337
+ let stderr = "";
1338
+ child.stdout.on("data", (value) => {
1339
+ stdout += String(value);
1340
+ });
1341
+ child.stderr.on("data", (value) => {
1342
+ stderr += String(value);
1343
+ });
1344
+ child.on("error", reject);
1345
+ child.on("close", (code) => {
1346
+ resolve({ code: code ?? 1, stdout, stderr });
1347
+ });
1348
+ });
1349
+ }
1350
+ function execArgs(manager, binary, args) {
1351
+ if (manager === "npm") {
1352
+ return { executable: "npm", args: ["exec", "--no", "--", binary, ...args] };
1353
+ }
1354
+ if (manager === "yarn") {
1355
+ return { executable: "yarn", args: ["exec", binary, ...args] };
1356
+ }
1357
+ if (manager === "bun") {
1358
+ return { executable: "bun", args: ["x", "--no-install", binary, ...args] };
1359
+ }
1360
+ return { executable: "pnpm", args: ["exec", binary, ...args] };
1361
+ }
1362
+ function scriptArgs(manager, name) {
1363
+ if (manager === "bun") {
1364
+ return { executable: "bun", args: ["run", name] };
1365
+ }
1366
+ if (manager === "npm") {
1367
+ return { executable: "npm", args: ["run", name] };
1368
+ }
1369
+ return { executable: manager, args: [name] };
1370
+ }
1371
+
1372
+ // src/audit.ts
1373
+ var AUDIT_TIMEOUT_MS = 6e4;
1374
+ async function checkAudit(options, dependencies = {}) {
1375
+ const mode = options.audit ?? "report";
1376
+ if (mode === "off") {
1377
+ return skipped("audit", "Dependency auditing is off.");
1378
+ }
1379
+ options.signal?.throwIfAborted();
1380
+ let invalidIgnores = [];
1381
+ let ignores = [];
1382
+ try {
1383
+ const parsed = await auditIgnores(options.cwd);
1384
+ ignores = parsed.ignores;
1385
+ invalidIgnores = parsed.problems;
1386
+ } catch (error) {
1387
+ invalidIgnores.push(
1388
+ problem(
1389
+ `Could not use audit.ignore: ${message2(error)}`,
1390
+ "Fix or remove the invalid acknowledgement. The advisory remains unacknowledged.",
1391
+ { rule: "audit/invalid-ignore", file: "zappie.config.ts", line: 1 }
1392
+ )
1393
+ );
1394
+ }
1395
+ if (options.packageManager === "yarn" || options.packageManager === "bun") {
1396
+ return withProblems(
1397
+ unavailable(
1398
+ mode,
1399
+ `Could not audit dependencies: ${options.packageManager} is not supported.`,
1400
+ "Use npm or pnpm for dependency auditing, or run with --audit=off.",
1401
+ "package.json"
1402
+ ),
1403
+ invalidIgnores,
1404
+ mode
1405
+ );
1406
+ }
1407
+ const lockfile2 = await findLockfile(options.cwd, options.packageManager);
1408
+ if (!lockfile2) {
1409
+ return withProblems(
1410
+ unavailable(
1411
+ mode,
1412
+ "Could not audit dependencies: no lockfile was found.",
1413
+ "Commit the package manager lockfile and run zappie check again.",
1414
+ "package.json"
1415
+ ),
1416
+ invalidIgnores,
1417
+ mode
1418
+ );
1419
+ }
1420
+ const invocation = auditCommand(options.packageManager);
1421
+ const timeoutMs = dependencies.timeoutMs ?? AUDIT_TIMEOUT_MS;
1422
+ const timeout = AbortSignal.timeout(timeoutMs);
1423
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
1424
+ let result;
1425
+ try {
1426
+ result = await (dependencies.run ?? command)(
1427
+ invocation.executable,
1428
+ invocation.args,
1429
+ options.cwd,
1430
+ signal
1431
+ );
1432
+ } catch (error) {
1433
+ options.signal?.throwIfAborted();
1434
+ const detail = timeout.aborted ? `the audit timed out after ${Math.ceil(timeoutMs / 1e3)} seconds` : message2(error);
1435
+ return withProblems(
1436
+ unavailable(
1437
+ mode,
1438
+ `Could not audit dependencies: ${detail}.`,
1439
+ "Check registry access and run zappie check again.",
1440
+ relativeLockfile(options.cwd, lockfile2)
1441
+ ),
1442
+ invalidIgnores,
1443
+ mode
1444
+ );
1445
+ }
1446
+ options.signal?.throwIfAborted();
1447
+ let advisories;
1448
+ try {
1449
+ advisories = parseAuditOutput(options.packageManager, result.stdout);
1450
+ if (result.code > 1) {
1451
+ throw new Error(`the audit command exited with status ${result.code}`);
1452
+ }
1453
+ } catch (error) {
1454
+ return withProblems(
1455
+ unavailable(
1456
+ mode,
1457
+ `Could not audit dependencies: ${message2(error)}.`,
1458
+ "Check registry access and run zappie check again.",
1459
+ relativeLockfile(options.cwd, lockfile2)
1460
+ ),
1461
+ invalidIgnores,
1462
+ mode
1463
+ );
1464
+ }
1465
+ return withProblems(
1466
+ evaluateAudit(
1467
+ advisories,
1468
+ mode,
1469
+ ignores,
1470
+ relativeLockfile(options.cwd, lockfile2)
1471
+ ),
1472
+ invalidIgnores,
1473
+ mode
1474
+ );
1475
+ }
1476
+ function evaluateAudit(advisories, mode, ignores = [], file) {
1477
+ const problems = [];
1478
+ const applied = advisories.map((advisory) => {
1479
+ const acknowledged = ignores.find(
1480
+ ({ id }) => normalizedId(id) === normalizedId(advisory.id)
1481
+ );
1482
+ if (acknowledged && malicious(advisory.id)) {
1483
+ problems.push(
1484
+ problem(
1485
+ `${advisory.id} is a malicious-package advisory and cannot be acknowledged.`,
1486
+ `Remove ${advisory.package} and replace it with a trusted package or release.`,
1487
+ {
1488
+ rule: "audit/invalid-ignore",
1489
+ advisory,
1490
+ ...file ? { file, line: 1 } : {}
1491
+ }
1492
+ )
1493
+ );
1494
+ return advisory;
1495
+ }
1496
+ if (acknowledged && (advisory.fixedVersion || advisory.upgradeVersion || advisory.fixCommand)) {
1497
+ const upgradePackage = advisory.upgradePackage ?? advisory.package;
1498
+ const upgradeVersion = advisory.upgradeVersion ?? advisory.fixedVersion;
1499
+ const fix = advisory.fixCommand ? `Run ${advisory.fixCommand}.` : `Upgrade ${upgradePackage} to ${upgradeVersion} or later.`;
1500
+ const reason = advisory.fixCommand ? `${advisory.id} has an available fix and cannot be acknowledged.` : `${advisory.id} has a fixed version and cannot be acknowledged.`;
1501
+ problems.push(
1502
+ problem(reason, fix, {
1503
+ rule: "audit/invalid-ignore",
1504
+ advisory,
1505
+ ...file ? { file, line: 1 } : {}
1506
+ })
1507
+ );
1508
+ return advisory;
1509
+ }
1510
+ return acknowledged ? { ...advisory, ignored: true, ignoredUntil: acknowledged.until } : advisory;
1511
+ });
1512
+ problems.push(...applied.map((advisory) => advisoryProblem(advisory, file)));
1513
+ const critical = applied.some(
1514
+ ({ severity, ignored }) => severity === "critical" && !ignored
1515
+ );
1516
+ const invalidIgnore2 = problems.some(
1517
+ ({ rule }) => rule === "audit/invalid-ignore"
1518
+ );
1519
+ return step(
1520
+ "audit",
1521
+ mode === "enforce" && (critical || invalidIgnore2) ? "failed" : "passed",
1522
+ problems
1523
+ );
1524
+ }
1525
+ function unavailable(mode, message6, fix, file) {
1526
+ return step("audit", mode === "enforce" ? "failed" : "passed", [
1527
+ problem(message6, fix, { rule: "audit/unavailable", file, line: 1 })
1528
+ ]);
1529
+ }
1530
+ function withProblems(checked, extra, mode) {
1531
+ if (extra.length === 0) {
1532
+ return checked;
1533
+ }
1534
+ return step("audit", mode === "enforce" ? "failed" : checked.result, [
1535
+ ...extra,
1536
+ ...checked.problems
1537
+ ]);
1538
+ }
1539
+ async function findLockfile(cwd, manager) {
1540
+ const names = manager === "npm" ? ["package-lock.json", "npm-shrinkwrap.json"] : ["pnpm-lock.yaml"];
1541
+ let directory = path4.resolve(cwd);
1542
+ for (; ; ) {
1543
+ if (directory !== path4.resolve(cwd) && manager === "pnpm" && await workspaceExcludes(directory, path4.resolve(cwd))) {
1544
+ return null;
1545
+ }
1546
+ for (const name of names) {
1547
+ const candidate = path4.join(directory, name);
1548
+ try {
1549
+ await access2(candidate);
1550
+ return candidate;
1551
+ } catch {
1552
+ }
1553
+ }
1554
+ const parent = path4.dirname(directory);
1555
+ if (parent === directory) {
1556
+ return null;
1557
+ }
1558
+ directory = parent;
1559
+ }
1560
+ }
1561
+ async function workspaceExcludes(directory, cwd) {
1562
+ try {
1563
+ const source = await readFile4(
1564
+ path4.join(directory, "pnpm-workspace.yaml"),
1565
+ "utf8"
1566
+ );
1567
+ const block = /^packages:\s*\n((?:[ \t]+.*(?:\n|$))*)/m.exec(source)?.[1];
1568
+ if (!block) {
1569
+ return false;
1570
+ }
1571
+ const relative2 = path4.relative(directory, cwd).replaceAll(path4.sep, "/");
1572
+ const patterns = [
1573
+ ...block.matchAll(/^\s*-\s*["']?([^"'\s]+)["']?\s*$/gm)
1574
+ ].map((match) => match[1] ?? "");
1575
+ return !patterns.some((pattern) => workspacePattern(pattern, relative2));
1576
+ } catch {
1577
+ return false;
1578
+ }
1579
+ }
1580
+ function workspacePattern(pattern, relative2) {
1581
+ if (pattern === ".") {
1582
+ return relative2 === "";
1583
+ }
1584
+ const expression = pattern.split("*").map((part) => part.replace(/[.*+?^$()|[\]\\]/g, "\\$&")).join("[^/]+");
1585
+ return new RegExp(`^${expression}$`).test(relative2);
1586
+ }
1587
+ function relativeLockfile(cwd, lockfile2) {
1588
+ return path4.relative(cwd, lockfile2).replaceAll(path4.sep, "/");
1589
+ }
1590
+ function auditCommand(manager) {
1591
+ return manager === "pnpm" ? { executable: "pnpm", args: ["audit", "--json"] } : { executable: "npm", args: ["audit", "--json"] };
1592
+ }
1593
+ function parseAuditOutput(manager, source) {
1594
+ const value = JSON.parse(source);
1595
+ requireAuditReport(manager, value);
1596
+ const parsed = manager === "npm" ? npmAdvisories(value) : pnpmAdvisories(value);
1597
+ return parsed.filter(({ severity }) => severity === "high" || severity === "critical").sort((left, right) => left.id.localeCompare(right.id));
1598
+ }
1599
+ function requireAuditReport(manager, value) {
1600
+ if (record3(value.error)) {
1601
+ throw new Error(
1602
+ string(value.error.message) ?? string(value.error.code) ?? string(value.message) ?? "the package manager returned an error"
1603
+ );
1604
+ }
1605
+ if (manager === "npm" && !record3(value.vulnerabilities)) {
1606
+ throw new Error("npm returned no vulnerability report");
1607
+ }
1608
+ if (manager === "pnpm" && !record3(value.advisories) && !record3(value.metadata)) {
1609
+ throw new Error("pnpm returned no advisory report");
1610
+ }
1611
+ }
1612
+ function pnpmAdvisories(value) {
1613
+ if (!record3(value.advisories)) {
1614
+ return [];
1615
+ }
1616
+ return Object.entries(value.advisories).flatMap(([key, raw]) => {
1617
+ if (!record3(raw)) {
1618
+ return [];
1619
+ }
1620
+ const findings = Array.isArray(raw.findings) ? raw.findings : [];
1621
+ const versions = findings.flatMap(
1622
+ (finding) => record3(finding) && typeof finding.version === "string" ? [finding.version] : []
1623
+ );
1624
+ const severity = normalizedSeverity(raw.severity);
1625
+ if (!severity) {
1626
+ return [];
1627
+ }
1628
+ return [
1629
+ {
1630
+ id: string(raw.github_advisory_id) ?? string(raw.id) ?? key,
1631
+ package: string(raw.module_name) ?? "unknown package",
1632
+ severity,
1633
+ vulnerableVersion: versions.join(", ") || string(raw.vulnerable_versions) || "unknown",
1634
+ fixedVersion: firstFixed(string(raw.patched_versions)),
1635
+ ignored: false
1636
+ }
1637
+ ];
1638
+ });
1639
+ }
1640
+ function npmAdvisories(value) {
1641
+ if (!record3(value.vulnerabilities)) {
1642
+ return [];
1643
+ }
1644
+ const advisories = /* @__PURE__ */ new Map();
1645
+ for (const [packageName2, raw] of Object.entries(value.vulnerabilities)) {
1646
+ if (!record3(raw) || !Array.isArray(raw.via)) {
1647
+ continue;
1648
+ }
1649
+ for (const via of raw.via) {
1650
+ if (!record3(via)) {
1651
+ continue;
1652
+ }
1653
+ const severity = normalizedSeverity(via.severity);
1654
+ if (!severity) {
1655
+ continue;
1656
+ }
1657
+ const id = ghsaFrom(string(via.url)) ?? string(via.name) ?? String(via.source ?? packageName2);
1658
+ const upgrade = npmUpgrade(raw.fixAvailable);
1659
+ advisories.set(id, {
1660
+ id,
1661
+ package: packageName2,
1662
+ severity,
1663
+ vulnerableVersion: string(via.range) ?? string(raw.range) ?? "unknown",
1664
+ fixedVersion: upgrade.upgradePackage === packageName2 ? upgrade.upgradeVersion ?? null : null,
1665
+ ...upgrade,
1666
+ ignored: false
1667
+ });
1668
+ }
1669
+ }
1670
+ return [...advisories.values()];
1671
+ }
1672
+ function advisoryProblem(advisory, file) {
1673
+ const acknowledged = advisory.ignored ? ` Acknowledged until ${advisory.ignoredUntil}.` : "";
1674
+ const upgradePackage = advisory.upgradePackage ?? advisory.package;
1675
+ const upgradeVersion = advisory.upgradeVersion ?? advisory.fixedVersion;
1676
+ const fix = malicious(advisory.id) ? `Remove ${advisory.package} and replace it with a trusted package or release.` : advisory.fixCommand ? `Run ${advisory.fixCommand}.` : upgradeVersion ? `Upgrade ${upgradePackage} to ${upgradeVersion} or later.` : `Review ${advisory.id}. If no fix exists, add a reason and an expiry of at most 90 days to audit.ignore.`;
1677
+ return problem(
1678
+ `${advisory.id}: ${advisory.package} ${advisory.vulnerableVersion} has ${advisory.severity} severity.${acknowledged}`,
1679
+ fix,
1680
+ {
1681
+ rule: `audit/${advisory.id}`,
1682
+ advisory,
1683
+ ...file ? { file, line: 1 } : {}
1684
+ }
1685
+ );
1686
+ }
1687
+ async function auditIgnores(cwd) {
1688
+ let loaded;
1689
+ try {
1690
+ loaded = await loadConfig(cwd);
1691
+ } catch (error) {
1692
+ if (missing(error)) {
1693
+ return { ignores: [], problems: [] };
1694
+ }
1695
+ throw error;
1696
+ }
1697
+ if (!record3(loaded.value) || !record3(loaded.value.audit)) {
1698
+ return { ignores: [], problems: [] };
1699
+ }
1700
+ const problems = [];
1701
+ const ignores = parseAuditIgnores(loaded.value.audit.ignore, problems);
1702
+ return { ignores, problems };
1703
+ }
1704
+ function parseAuditIgnores(value, problems = []) {
1705
+ if (value === void 0) {
1706
+ return [];
1707
+ }
1708
+ if (!Array.isArray(value)) {
1709
+ problems.push(invalidIgnore("audit.ignore must be an array."));
1710
+ return [];
1711
+ }
1712
+ const today = Date.parse(
1713
+ `${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}T00:00:00.000Z`
1714
+ );
1715
+ const ninetyDays = 90 * 24 * 60 * 60 * 1e3;
1716
+ const ids = /* @__PURE__ */ new Set();
1717
+ const ignores = [];
1718
+ value.forEach((entry, index) => {
1719
+ if (!ignoreEntry(entry) || !entry.id.trim() || !entry.reason.trim()) {
1720
+ problems.push(
1721
+ invalidIgnore(
1722
+ `audit.ignore[${index}] must have a non-blank id, reason and until.`
1723
+ )
1724
+ );
1725
+ return;
1726
+ }
1727
+ const id = normalizedId(entry.id);
1728
+ if (ids.has(id)) {
1729
+ problems.push(
1730
+ invalidIgnore(`audit.ignore contains duplicate id ${entry.id}.`)
1731
+ );
1732
+ return;
1733
+ }
1734
+ const expiry = Date.parse(`${entry.until}T00:00:00.000Z`);
1735
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.until) || !Number.isFinite(expiry) || new Date(expiry).toISOString().slice(0, 10) !== entry.until || expiry < today || expiry - today > ninetyDays) {
1736
+ problems.push(
1737
+ invalidIgnore(
1738
+ `audit.ignore[${index}].until must be a real, unexpired date at most 90 days away.`
1739
+ )
1740
+ );
1741
+ return;
1742
+ }
1743
+ ids.add(id);
1744
+ ignores.push(entry);
1745
+ });
1746
+ return ignores;
1747
+ }
1748
+ function invalidIgnore(message6) {
1749
+ return problem(
1750
+ message6,
1751
+ "Fix or remove the invalid acknowledgement. The advisory remains unacknowledged.",
1752
+ { rule: "audit/invalid-ignore", file: "zappie.config.ts", line: 1 }
1753
+ );
1754
+ }
1755
+ function ignoreEntry(value) {
1756
+ return record3(value) && typeof value.id === "string" && typeof value.reason === "string" && typeof value.until === "string";
1757
+ }
1758
+ function npmUpgrade(value) {
1759
+ if (record3(value) && typeof value.name === "string" && typeof value.version === "string") {
1760
+ return { upgradePackage: value.name, upgradeVersion: value.version };
1761
+ }
1762
+ if (value === true) {
1763
+ return { fixCommand: "npm audit fix" };
1764
+ }
1765
+ return {};
1766
+ }
1767
+ function firstFixed(value) {
1768
+ if (!value || value === "<0.0.0") {
1769
+ return null;
1770
+ }
1771
+ return /(?:^|\s)>=?\s*([^\s|]+)/.exec(value)?.[1] ?? null;
1772
+ }
1773
+ function ghsaFrom(value) {
1774
+ const id = value ? /GHSA-[\w-]+/i.exec(value)?.[0] : void 0;
1775
+ return id ? `GHSA-${id.slice(5).toLowerCase()}` : void 0;
1776
+ }
1777
+ function normalizedId(value) {
1778
+ return value.trim().toLowerCase();
1779
+ }
1780
+ function malicious(value) {
1781
+ return /^MAL-/i.test(value.trim());
1782
+ }
1783
+ function normalizedSeverity(value) {
1784
+ const severity = typeof value === "string" ? value.toLowerCase() : "";
1785
+ return severity === "high" || severity === "critical" ? severity : void 0;
1786
+ }
1787
+ function string(value) {
1788
+ return typeof value === "string" ? value : void 0;
1789
+ }
1790
+ function message2(error) {
1791
+ return error instanceof Error ? error.message : String(error);
1792
+ }
1793
+ function missing(error) {
1794
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
1795
+ }
1796
+ function record3(value) {
1797
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1798
+ }
1799
+
1321
1800
  // src/budgets.ts
1322
- import { access as access2, readFile as readFile6 } from "fs/promises";
1323
- import path6 from "path";
1801
+ import { access as access3, readFile as readFile7 } from "fs/promises";
1802
+ import path7 from "path";
1324
1803
 
1325
1804
  // src/budget-files.ts
1326
- import { readFile as readFile4, readdir } from "fs/promises";
1327
- import path4 from "path";
1805
+ import { readFile as readFile5, readdir } from "fs/promises";
1806
+ import path5 from "path";
1328
1807
  import { gzipSync } from "zlib";
1329
1808
  async function cssSizes(cwd) {
1330
- const root = path4.join(cwd, "build/client");
1809
+ const root = path5.join(cwd, "build/client");
1331
1810
  const files = [];
1332
1811
  await collect(root, files);
1333
1812
  return Promise.all(
1334
1813
  files.filter((file) => file.endsWith(".css")).map(async (file) => ({
1335
- file: path4.relative(root, file).split(path4.sep).join("/"),
1336
- bytes: gzipSync(await readFile4(file, "utf8")).byteLength
1814
+ file: path5.relative(root, file).split(path5.sep).join("/"),
1815
+ bytes: gzipSync(await readFile5(file, "utf8")).byteLength
1337
1816
  }))
1338
1817
  );
1339
1818
  }
@@ -1342,7 +1821,7 @@ async function collect(directory, files) {
1342
1821
  if (entry.isSymbolicLink()) {
1343
1822
  continue;
1344
1823
  }
1345
- const file = path4.join(directory, entry.name);
1824
+ const file = path5.join(directory, entry.name);
1346
1825
  if (entry.isDirectory()) {
1347
1826
  await collect(file, files);
1348
1827
  } else if (entry.isFile()) {
@@ -1352,8 +1831,8 @@ async function collect(directory, files) {
1352
1831
  }
1353
1832
 
1354
1833
  // src/budget-manifest.ts
1355
- import { readFile as readFile5 } from "fs/promises";
1356
- import path5 from "path";
1834
+ import { readFile as readFile6 } from "fs/promises";
1835
+ import path6 from "path";
1357
1836
  import { gzipSync as gzipSync2 } from "zlib";
1358
1837
  function routeKeys(manifest) {
1359
1838
  return Object.entries(manifest).filter(([key, entry]) => routeSource(entry.src ?? key)).map(([key]) => key).sort();
@@ -1367,7 +1846,7 @@ async function graphSizes(cwd, manifest, roots) {
1367
1846
  [...keys].map((key) => manifest[key]).filter((entry) => Boolean(entry)).filter(({ file }) => file.endsWith(".js")).map(async ({ file }) => ({
1368
1847
  file,
1369
1848
  bytes: gzipSync2(
1370
- await readFile5(path5.join(cwd, "build/client", file), "utf8")
1849
+ await readFile6(path6.join(cwd, "build/client", file), "utf8")
1371
1850
  ).byteLength
1372
1851
  }))
1373
1852
  );
@@ -1401,16 +1880,16 @@ async function checkBudgets({
1401
1880
  signal
1402
1881
  }) {
1403
1882
  signal?.throwIfAborted();
1404
- const manifestFile = path6.join(cwd, "build/client/.vite/manifest.json");
1883
+ const manifestFile = path7.join(cwd, "build/client/.vite/manifest.json");
1405
1884
  try {
1406
- await access2(manifestFile);
1885
+ await access3(manifestFile);
1407
1886
  } catch {
1408
1887
  return skipped(
1409
1888
  "budgets",
1410
1889
  "No build/client/.vite/manifest.json is available."
1411
1890
  );
1412
1891
  }
1413
- const manifest = JSON.parse(await readFile6(manifestFile, "utf8"));
1892
+ const manifest = JSON.parse(await readFile7(manifestFile, "utf8"));
1414
1893
  const config = (await loadConfig(cwd)).value;
1415
1894
  const limits = { ...DEFAULT_BUDGETS, ...config.budgets };
1416
1895
  const routes = routeKeys(manifest);
@@ -1442,124 +1921,850 @@ async function checkBudgets({
1442
1921
  signal?.throwIfAborted();
1443
1922
  return step("budgets", failed ? "failed" : "passed", problems);
1444
1923
  }
1445
- function routeProblem(route, sizes, limitKb, over) {
1446
- const bytes = total(sizes);
1447
- const largest = [...sizes].sort((left, right) => right.bytes - left.bytes).slice(0, 3).map(({ file, bytes: size }) => `${file} (${size} B)`).join(", ");
1448
- return problem(
1449
- `${route}: ${bytes} B gzip (limit ${limitKb * KIB} B).${over ? ` Largest chunks: ${largest}.` : ""}`,
1450
- over ? "Reduce the route's client JavaScript or raise the reviewed budget override." : null,
1451
- {
1452
- rule: over ? "budgets/js-over" : "budgets/js-route",
1453
- file: "build/client/.vite/manifest.json"
1924
+ function routeProblem(route, sizes, limitKb, over) {
1925
+ const bytes = total(sizes);
1926
+ const largest = [...sizes].sort((left, right) => right.bytes - left.bytes).slice(0, 3).map(({ file, bytes: size }) => `${file} (${size} B)`).join(", ");
1927
+ return problem(
1928
+ `${route}: ${bytes} B gzip (limit ${limitKb * KIB} B).${over ? ` Largest chunks: ${largest}.` : ""}`,
1929
+ over ? "Reduce the route's client JavaScript or raise the reviewed budget override." : null,
1930
+ {
1931
+ rule: over ? "budgets/js-over" : "budgets/js-route",
1932
+ file: "build/client/.vite/manifest.json"
1933
+ }
1934
+ );
1935
+ }
1936
+ function cssProblem(sizes, limitKb, over) {
1937
+ const bytes = total(sizes);
1938
+ return problem(
1939
+ `Client CSS: ${bytes} B gzip (limit ${limitKb * KIB} B).`,
1940
+ over ? "Reduce client CSS or raise the reviewed budget override." : null,
1941
+ { rule: over ? "budgets/css-over" : "budgets/css" }
1942
+ );
1943
+ }
1944
+ var total = (sizes) => sizes.reduce((sum, asset) => sum + asset.bytes, 0);
1945
+
1946
+ // src/build.ts
1947
+ import { readFile as readFile8 } from "fs/promises";
1948
+ import path8 from "path";
1949
+ async function checkBuild({
1950
+ cwd,
1951
+ packageManager,
1952
+ signal
1953
+ }) {
1954
+ signal?.throwIfAborted();
1955
+ const manifest = JSON.parse(
1956
+ await readFile8(path8.join(cwd, "package.json"), "utf8")
1957
+ );
1958
+ const invocation = manifest.dependencies?.["react-router"] ? execArgs(packageManager, "react-router", ["build"]) : manifest.scripts?.build ? scriptArgs(packageManager, "build") : void 0;
1959
+ if (!invocation) {
1960
+ return skipped(
1961
+ "build",
1962
+ "No react-router dependency or build script is defined in package.json."
1963
+ );
1964
+ }
1965
+ const result = await command(
1966
+ invocation.executable,
1967
+ invocation.args,
1968
+ cwd,
1969
+ signal
1970
+ );
1971
+ signal?.throwIfAborted();
1972
+ if (result.code === 0) {
1973
+ return step("build", "passed");
1974
+ }
1975
+ return step("build", "failed", [
1976
+ problem(
1977
+ `The project build failed.
1978
+ ${usefulTail(result.stdout, result.stderr)}`,
1979
+ "Fix the reported build errors and run zappie check again.",
1980
+ { rule: "build/failed" }
1981
+ )
1982
+ ]);
1983
+ }
1984
+ function usefulTail(stdout, stderr) {
1985
+ const lines = `${stdout}
1986
+ ${stderr}`.split(/\r?\n/).map((line) => line.trimEnd()).filter(Boolean).slice(-12);
1987
+ return lines.length > 0 ? lines.join("\n") : "The build exited without output.";
1988
+ }
1989
+
1990
+ // src/css-files.ts
1991
+ import { lstat, readFile as readFile9, readdir as readdir2 } from "fs/promises";
1992
+ import path9 from "path";
1993
+ var FILE_SIZE_CAP = 1024 * 1024;
1994
+ var BUILD_FILE_SIZE_CAP = 8 * 1024 * 1024;
1995
+ var ignoredDirectories = /* @__PURE__ */ new Set([
1996
+ "node_modules",
1997
+ "build",
1998
+ "dist",
1999
+ "coverage",
2000
+ "public",
2001
+ "vendor",
2002
+ "test",
2003
+ "tests",
2004
+ "e2e",
2005
+ "test-results",
2006
+ "playwright-report"
2007
+ ]);
2008
+ var codeExtension = /\.(?:[cm]?[jt]sx?|html|mdx)$/;
2009
+ var testFile = /\.(?:test|spec)\.[^.]+$/;
2010
+ async function readCssInputs(cwd) {
2011
+ const source = [];
2012
+ await collect2(cwd, source, (name) => {
2013
+ return !name.startsWith(".") && !ignoredDirectories.has(name);
2014
+ });
2015
+ const built = [];
2016
+ await collect2(path9.join(cwd, "build/client"), built, () => true);
2017
+ const read = (files, cap) => texts(cwd, files, cap);
2018
+ return {
2019
+ sourceCss: await read(
2020
+ source.filter((file) => file.endsWith(".css")),
2021
+ FILE_SIZE_CAP
2022
+ ),
2023
+ sourceCode: await read(
2024
+ source.filter(
2025
+ (file) => codeExtension.test(file) && !testFile.test(file) && !/(?:^|[/\\])[^/\\]+\.config\.[^.]+$/.test(file)
2026
+ ),
2027
+ FILE_SIZE_CAP
2028
+ ),
2029
+ builtCss: await read(
2030
+ built.filter((file) => file.endsWith(".css")),
2031
+ BUILD_FILE_SIZE_CAP
2032
+ ),
2033
+ builtCode: await read(
2034
+ built.filter((file) => file.endsWith(".js")),
2035
+ BUILD_FILE_SIZE_CAP
2036
+ )
2037
+ };
2038
+ }
2039
+ async function texts(cwd, files, cap) {
2040
+ const result = [];
2041
+ for (const file of files.sort()) {
2042
+ const info = await lstat(file);
2043
+ if (!info.isFile() || info.size > cap) {
2044
+ continue;
2045
+ }
2046
+ result.push({
2047
+ file: relative(cwd, file),
2048
+ text: await readFile9(file, "utf8")
2049
+ });
2050
+ }
2051
+ return result;
2052
+ }
2053
+ async function collect2(directory, files, enter) {
2054
+ let entries;
2055
+ try {
2056
+ entries = await readdir2(directory, { withFileTypes: true });
2057
+ } catch (error) {
2058
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
2059
+ return;
2060
+ }
2061
+ throw error;
2062
+ }
2063
+ for (const entry of entries) {
2064
+ if (entry.isSymbolicLink()) {
2065
+ continue;
2066
+ }
2067
+ const file = path9.join(directory, entry.name);
2068
+ if (entry.isDirectory()) {
2069
+ if (enter(entry.name)) {
2070
+ await collect2(file, files, enter);
2071
+ }
2072
+ } else if (entry.isFile()) {
2073
+ files.push(file);
2074
+ }
2075
+ }
2076
+ }
2077
+
2078
+ // src/css-rules.ts
2079
+ import postcss from "postcss";
2080
+ var CSS_ERRORS = /* @__PURE__ */ new Set([
2081
+ "css/important",
2082
+ "css/class-patch"
2083
+ ]);
2084
+ function analyseCss(inputs) {
2085
+ const source = parseAll(inputs.sourceCss);
2086
+ const built = parseAll(inputs.builtCss);
2087
+ return [
2088
+ ...source.problems,
2089
+ ...built.problems,
2090
+ ...important(source.parsed, inputs.sourceCode),
2091
+ ...duplicateKeyframes(source.parsed, built.parsed),
2092
+ ...duplicateSelectors(source.parsed),
2093
+ ...arbitraryTokens(source.parsed, inputs.sourceCode),
2094
+ ...unusedCustomProperties(source.parsed, built.parsed, inputs),
2095
+ ...reducedMotion(source.parsed),
2096
+ ...classPatches(source.parsed),
2097
+ ...literalTokens(source.parsed)
2098
+ ];
2099
+ }
2100
+ function parseAll(files) {
2101
+ const parsed = [];
2102
+ const problems = [];
2103
+ for (const entry of files) {
2104
+ try {
2105
+ parsed.push({
2106
+ ...entry,
2107
+ root: postcss.parse(entry.text, { from: entry.file }),
2108
+ lines: entry.text.split(/\r?\n/)
2109
+ });
2110
+ } catch (error) {
2111
+ const line = error && typeof error === "object" && "line" in error ? Number(error.line) : void 0;
2112
+ problems.push(
2113
+ problem(
2114
+ `${entry.file} could not be read as CSS: ${error instanceof Error ? error.message : String(error)}`,
2115
+ "Fix the CSS syntax so zappie check can read the file.",
2116
+ {
2117
+ rule: "css/unreadable",
2118
+ file: entry.file,
2119
+ ...line && line > 0 ? { line } : {}
2120
+ }
2121
+ )
2122
+ );
2123
+ }
2124
+ }
2125
+ return { parsed, problems };
2126
+ }
2127
+ var allowImportant = /zappie-allow-important:\s*\S/;
2128
+ function important(css, code) {
2129
+ const problems = [];
2130
+ const allowed = (lines, line) => line !== void 0 && allowImportant.test(lines[line - 1] ?? "");
2131
+ for (const entry of css) {
2132
+ entry.root.walkDecls((decl) => {
2133
+ const line = decl.source?.start?.line;
2134
+ if (decl.important && !allowed(entry.lines, line) && !insideMedia(decl.parent, "reduce")) {
2135
+ problems.push(
2136
+ importantProblem(
2137
+ `${decl.prop} is marked !important.`,
2138
+ entry.file,
2139
+ line
2140
+ )
2141
+ );
2142
+ }
2143
+ });
2144
+ entry.root.walkAtRules("apply", (rule) => {
2145
+ const line = rule.source?.start?.line;
2146
+ const flagged = rule.params.split(/\s+/).filter((token) => importantUtility(token));
2147
+ if ((flagged.length > 0 || /!important\b/.test(rule.params)) && !allowed(entry.lines, line)) {
2148
+ problems.push(
2149
+ importantProblem(
2150
+ `@apply ${rule.params} forces !important.`,
2151
+ entry.file,
2152
+ line
2153
+ )
2154
+ );
2155
+ }
2156
+ });
2157
+ }
2158
+ for (const entry of code) {
2159
+ const lines = entry.text.split(/\r?\n/);
2160
+ lines.forEach((text, index) => {
2161
+ if (allowImportant.test(text)) {
2162
+ return;
2163
+ }
2164
+ for (const list of classLists(text)) {
2165
+ for (const token of list.split(/\s+/)) {
2166
+ if (importantUtility(token)) {
2167
+ problems.push(
2168
+ importantProblem(
2169
+ `The utility ${token} forces !important.`,
2170
+ entry.file,
2171
+ index + 1
2172
+ )
2173
+ );
2174
+ }
2175
+ }
2176
+ }
2177
+ });
2178
+ }
2179
+ return problems;
2180
+ }
2181
+ function importantProblem(message6, file, line) {
2182
+ return problem(
2183
+ message6,
2184
+ "Raise the selector's specificity or reorder the rules instead. For a reviewed case, add /* zappie-allow-important: <reason> */ on the same line.",
2185
+ { rule: "css/important", file, ...line ? { line } : {} }
2186
+ );
2187
+ }
2188
+ var utilityToken = /^!?-?[a-z0-9@[\]_\-:/.%()#,'=&>*~+!]*[a-z0-9\])%]!?$/;
2189
+ function importantUtility(token) {
2190
+ const utility = withoutVariants(token).utility;
2191
+ if (utility.length < 3) {
2192
+ return false;
2193
+ }
2194
+ if (/^!-?[a-z[]/.test(utility)) {
2195
+ return true;
2196
+ }
2197
+ return /[a-z0-9\])%]!$/.test(utility) && /[-[]/.test(utility);
2198
+ }
2199
+ function classLists(line) {
2200
+ const lists = [];
2201
+ for (const match of line.matchAll(/(["'`])([^"'`]*?)\1/g)) {
2202
+ const value = match[2]?.trim() ?? "";
2203
+ if (!value) {
2204
+ continue;
2205
+ }
2206
+ const tokens = value.split(/\s+/);
2207
+ if (tokens.every((token) => utilityToken.test(token)) && tokens.some((token) => token.includes("-"))) {
2208
+ lists.push(value);
2209
+ }
2210
+ }
2211
+ return lists;
2212
+ }
2213
+ function withoutVariants(token) {
2214
+ let depth = 0;
2215
+ let cut = 0;
2216
+ for (let index = 0; index < token.length; index++) {
2217
+ const character = token[index];
2218
+ if (character === "[" || character === "(") {
2219
+ depth++;
2220
+ } else if (character === "]" || character === ")") {
2221
+ depth--;
2222
+ } else if (character === ":" && depth === 0) {
2223
+ cut = index + 1;
2224
+ }
2225
+ }
2226
+ return { variants: token.slice(0, cut), utility: token.slice(cut) };
2227
+ }
2228
+ function keyframesIn(files) {
2229
+ const found = [];
2230
+ for (const entry of files) {
2231
+ entry.root.walkAtRules(/keyframes$/i, (rule) => {
2232
+ const line = rule.source?.start?.line;
2233
+ found.push({
2234
+ name: rule.params.trim(),
2235
+ vendor: rule.name.toLowerCase().replace(/keyframes$/, ""),
2236
+ body: framesBody(rule),
2237
+ file: entry.file,
2238
+ ...line ? { line } : {}
2239
+ });
2240
+ });
2241
+ }
2242
+ return found;
2243
+ }
2244
+ function framesBody(rule) {
2245
+ const frames = [];
2246
+ rule.each((node) => {
2247
+ if (node.type !== "rule") {
2248
+ return;
2249
+ }
2250
+ const declarations = [];
2251
+ node.walkDecls((decl) => {
2252
+ declarations.push(
2253
+ `${decl.prop.toLowerCase()}:${normaliseValue(decl.value)}${decl.important ? "!" : ""}`
2254
+ );
2255
+ });
2256
+ const selector = node.selectors.map(
2257
+ (value) => value.trim().toLowerCase().replace(/^from$/, "0%").replace(/^to$/, "100%")
2258
+ ).join(",");
2259
+ frames.push(`${selector}{${declarations.join(";")}}`);
2260
+ });
2261
+ return frames.join("");
2262
+ }
2263
+ function normaliseValue(value) {
2264
+ return value.toLowerCase().replace(/\s+/g, "").replace(/(^|[^\d.])0+(\.\d)/g, "$1$2").replace(/(\d)\.0+(?!\d)/g, "$1");
2265
+ }
2266
+ function duplicateKeyframes(source, built) {
2267
+ const problems = [];
2268
+ const reported = /* @__PURE__ */ new Set();
2269
+ const sourceFrames = keyframesIn(source);
2270
+ const builtFrames = keyframesIn(built);
2271
+ const fix = "Keep one @keyframes for each animation and refer to it by that name.";
2272
+ const byName = (frames) => {
2273
+ const groups = /* @__PURE__ */ new Map();
2274
+ for (const frame of frames) {
2275
+ const key = `${frame.vendor}${frame.name}`;
2276
+ groups.set(key, [...groups.get(key) ?? [], frame]);
2277
+ }
2278
+ return groups;
2279
+ };
2280
+ for (const [key, group] of byName(sourceFrames)) {
2281
+ if (group.length > 1) {
2282
+ reported.add(key);
2283
+ const [first, ...rest] = group;
2284
+ for (const frame of rest) {
2285
+ problems.push(
2286
+ problem(
2287
+ `@keyframes ${frame.name} is defined again (first in ${first?.file}${first?.line ? `:${first.line}` : ""}).`,
2288
+ fix,
2289
+ location("css/duplicate-keyframes", frame)
2290
+ )
2291
+ );
2292
+ }
2293
+ }
2294
+ }
2295
+ for (const [key, group] of byName(builtFrames)) {
2296
+ if (reported.has(key) || new Set(group.map(({ body }) => body)).size < 2) {
2297
+ continue;
2298
+ }
2299
+ reported.add(key);
2300
+ const first = group[0];
2301
+ problems.push(
2302
+ problem(
2303
+ `@keyframes ${first?.name} is defined more than once with different frames in the built CSS, so the last one wins.`,
2304
+ `${fix} Rename yours if it clashes with one from a library or Tailwind.`,
2305
+ location("css/duplicate-keyframes", first)
2306
+ )
2307
+ );
2308
+ }
2309
+ const byBody = /* @__PURE__ */ new Map();
2310
+ for (const frame of [...sourceFrames, ...builtFrames]) {
2311
+ if (!frame.body) {
2312
+ continue;
2313
+ }
2314
+ const key = `${frame.vendor}${frame.body}`;
2315
+ const group = byBody.get(key) ?? [];
2316
+ if (!group.some(({ name }) => name === frame.name)) {
2317
+ group.push(frame);
2318
+ }
2319
+ byBody.set(key, group);
2320
+ }
2321
+ for (const group of byBody.values()) {
2322
+ if (group.length < 2) {
2323
+ continue;
2324
+ }
2325
+ const names = group.map(({ name }) => name);
2326
+ const [first, ...rest] = group;
2327
+ problems.push(
2328
+ problem(
2329
+ `@keyframes ${names.join(", ")} have the same frames under different names.`,
2330
+ `${fix} Use ${first?.name} for all of them.`,
2331
+ location("css/duplicate-keyframes", rest[0])
2332
+ )
2333
+ );
2334
+ }
2335
+ return problems;
2336
+ }
2337
+ function location(rule, entry) {
2338
+ return {
2339
+ rule,
2340
+ ...entry ? { file: entry.file } : {},
2341
+ ...entry?.line ? { line: entry.line } : {}
2342
+ };
2343
+ }
2344
+ function contextOf(node) {
2345
+ const parts = [];
2346
+ for (let parent = node; parent && parent.type !== "root"; parent = parent.parent) {
2347
+ if (parent.type === "atrule") {
2348
+ const rule = parent;
2349
+ parts.unshift(`@${rule.name} ${rule.params.replace(/\s+/g, " ").trim()}`);
2350
+ } else if (parent.type === "rule") {
2351
+ parts.unshift(
2352
+ parent.selectors.map(normaliseSelector).join(",")
2353
+ );
1454
2354
  }
1455
- );
2355
+ }
2356
+ return parts.join(" > ");
1456
2357
  }
1457
- function cssProblem(sizes, limitKb, over) {
1458
- const bytes = total(sizes);
1459
- return problem(
1460
- `Client CSS: ${bytes} B gzip (limit ${limitKb * KIB} B).`,
1461
- over ? "Reduce client CSS or raise the reviewed budget override." : null,
1462
- { rule: over ? "budgets/css-over" : "budgets/css" }
1463
- );
2358
+ var normaliseSelector = (selector) => selector.replace(/\s+/g, " ").replace(/\s*([>+~,])\s*/g, "$1").trim();
2359
+ function duplicateSelectors(source) {
2360
+ const seen = /* @__PURE__ */ new Map();
2361
+ const problems = [];
2362
+ for (const entry of source) {
2363
+ entry.root.walkRules((rule) => {
2364
+ if (rule.parent?.type === "atrule" && /keyframes$/i.test(rule.parent.name)) {
2365
+ return;
2366
+ }
2367
+ const context = contextOf(rule.parent);
2368
+ const line = rule.source?.start?.line;
2369
+ const selectors = [...new Set(rule.selectors.map(normaliseSelector))];
2370
+ const keys = selectors.length === 1 ? selectors : [[...selectors].sort().join(",")];
2371
+ for (const selector of keys) {
2372
+ const key = `${context}\0${selector}`;
2373
+ const first = seen.get(key);
2374
+ if (!first) {
2375
+ seen.set(key, { file: entry.file, ...line ? { line } : {} });
2376
+ continue;
2377
+ }
2378
+ problems.push(
2379
+ problem(
2380
+ `${selector}${context ? ` (inside ${context})` : ""} is already styled in ${first.file}${first.line ? `:${first.line}` : ""}.`,
2381
+ "Merge the declarations into one rule so the cascade order cannot change the result.",
2382
+ location("css/duplicate-selector", {
2383
+ file: entry.file,
2384
+ ...line ? { line } : {}
2385
+ })
2386
+ )
2387
+ );
2388
+ }
2389
+ });
2390
+ }
2391
+ return problems;
1464
2392
  }
1465
- var total = (sizes) => sizes.reduce((sum, asset) => sum + asset.bytes, 0);
1466
-
1467
- // src/build.ts
1468
- import { readFile as readFile7 } from "fs/promises";
1469
- import path7 from "path";
1470
-
1471
- // src/command.ts
1472
- import { spawn } from "child_process";
1473
- function command(executable, args, cwd, signal) {
1474
- return new Promise((resolve, reject) => {
1475
- const child = spawn(executable, args, {
1476
- cwd,
1477
- env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
1478
- stdio: ["ignore", "pipe", "pipe"],
1479
- ...signal ? { signal } : {}
2393
+ var namespaces = [
2394
+ ["font-weight", ["font"]],
2395
+ ["inset-shadow", ["inset-shadow"]],
2396
+ ["drop-shadow", ["drop-shadow"]],
2397
+ ["text-shadow", ["text-shadow"]],
2398
+ [
2399
+ "color",
2400
+ [
2401
+ "text",
2402
+ "bg",
2403
+ "border",
2404
+ "border-x",
2405
+ "border-y",
2406
+ "border-t",
2407
+ "border-r",
2408
+ "border-b",
2409
+ "border-l",
2410
+ "border-s",
2411
+ "border-e",
2412
+ "ring",
2413
+ "ring-offset",
2414
+ "outline",
2415
+ "fill",
2416
+ "stroke",
2417
+ "decoration",
2418
+ "accent",
2419
+ "caret",
2420
+ "divide",
2421
+ "placeholder",
2422
+ "from",
2423
+ "via",
2424
+ "to",
2425
+ "shadow",
2426
+ "inset-shadow",
2427
+ "inset-ring"
2428
+ ]
2429
+ ],
2430
+ [
2431
+ "radius",
2432
+ [
2433
+ "rounded",
2434
+ "rounded-t",
2435
+ "rounded-r",
2436
+ "rounded-b",
2437
+ "rounded-l",
2438
+ "rounded-tl",
2439
+ "rounded-tr",
2440
+ "rounded-br",
2441
+ "rounded-bl",
2442
+ "rounded-s",
2443
+ "rounded-e",
2444
+ "rounded-ss",
2445
+ "rounded-se",
2446
+ "rounded-es",
2447
+ "rounded-ee"
2448
+ ]
2449
+ ],
2450
+ ["font", ["font"]],
2451
+ ["text", ["text"]],
2452
+ ["tracking", ["tracking"]],
2453
+ ["leading", ["leading"]],
2454
+ ["shadow", ["shadow"]],
2455
+ ["blur", ["blur"]],
2456
+ ["ease", ["ease"]],
2457
+ ["animate", ["animate"]],
2458
+ ["container", ["max-w", "min-w", "w"]]
2459
+ ];
2460
+ function themeTokens(source) {
2461
+ const tokens = [];
2462
+ for (const entry of source) {
2463
+ entry.root.walkAtRules("theme", (rule) => {
2464
+ rule.walkDecls(/^--/, (decl) => {
2465
+ for (const [namespace, utilities] of namespaces) {
2466
+ const prefix = `--${namespace}-`;
2467
+ if (decl.prop.startsWith(prefix) && decl.prop.length > prefix.length) {
2468
+ tokens.push({
2469
+ variable: decl.prop,
2470
+ value: compact(decl.value),
2471
+ utilities,
2472
+ name: decl.prop.slice(prefix.length)
2473
+ });
2474
+ break;
2475
+ }
2476
+ }
2477
+ });
1480
2478
  });
1481
- let stdout = "";
1482
- let stderr = "";
1483
- child.stdout.on("data", (value) => {
1484
- stdout += String(value);
2479
+ }
2480
+ return tokens;
2481
+ }
2482
+ var compact = (value) => value.toLowerCase().replace(/_/g, " ").replace(/\s+/g, "");
2483
+ var arbitraryUtility = /(?<![\w-])((?:[^\s"'`<>{}]*:)?)(!?)(-?)([a-z][a-z-]*?)-(\[([^\]\s"'`]+)\]|\((--[a-z0-9_-]+)\))((?:\/[\w.%]+)?!?)(?![\w-])/g;
2484
+ function arbitraryTokens(source, code) {
2485
+ const tokens = themeTokens(source);
2486
+ if (tokens.length === 0) {
2487
+ return [];
2488
+ }
2489
+ const problems = [];
2490
+ const scan = (text, file, lineOffset = 0) => {
2491
+ text.split(/\r?\n/).forEach((line, index) => {
2492
+ for (const match of line.matchAll(arbitraryUtility)) {
2493
+ const [
2494
+ whole,
2495
+ variants = "",
2496
+ bang = "",
2497
+ negative = "",
2498
+ utility = "",
2499
+ ,
2500
+ bracket,
2501
+ parenthesis,
2502
+ modifier = ""
2503
+ ] = match;
2504
+ const raw = bracket ?? `var(${parenthesis})`;
2505
+ const value = compact(raw.replace(/^[a-z-]+:(?!\/)/, ""));
2506
+ const token = tokens.find(
2507
+ (candidate) => candidate.utilities.includes(utility) && (value === candidate.value || value === `var(${candidate.variable})`)
2508
+ );
2509
+ if (!token) {
2510
+ continue;
2511
+ }
2512
+ const replacement = `${variants}${bang}${negative}${utility}-${token.name}${modifier}`;
2513
+ problems.push(
2514
+ problem(
2515
+ `${whole} restates the theme token ${token.variable}.`,
2516
+ `Use ${replacement}.`,
2517
+ { rule: "css/arbitrary-token", file, line: lineOffset + index + 1 }
2518
+ )
2519
+ );
2520
+ }
1485
2521
  });
1486
- child.stderr.on("data", (value) => {
1487
- stderr += String(value);
2522
+ };
2523
+ for (const entry of code) {
2524
+ scan(entry.text, entry.file);
2525
+ }
2526
+ for (const entry of source) {
2527
+ entry.root.walkAtRules("apply", (rule) => {
2528
+ scan(rule.params, entry.file, (rule.source?.start?.line ?? 1) - 1);
1488
2529
  });
1489
- child.on("error", reject);
1490
- child.on("close", (code) => {
1491
- resolve({ code: code ?? 1, stdout, stderr });
2530
+ }
2531
+ return problems;
2532
+ }
2533
+ function unusedCustomProperties(source, built, inputs) {
2534
+ const definitions = /* @__PURE__ */ new Map();
2535
+ for (const entry of source) {
2536
+ entry.root.walkDecls(/^--/, (decl) => {
2537
+ if (insideTheme(decl.parent) || definitions.has(decl.prop)) {
2538
+ return;
2539
+ }
2540
+ const line = decl.source?.start?.line;
2541
+ definitions.set(decl.prop, {
2542
+ file: entry.file,
2543
+ ...line ? { line } : {}
2544
+ });
1492
2545
  });
1493
- });
2546
+ }
2547
+ if (definitions.size === 0) {
2548
+ return [];
2549
+ }
2550
+ const read = /* @__PURE__ */ new Set();
2551
+ const cssReads = /var\(\s*(--[\w-]+)/g;
2552
+ for (const entry of [...source, ...built]) {
2553
+ for (const match of entry.text.matchAll(cssReads)) {
2554
+ read.add(match[1] ?? "");
2555
+ }
2556
+ }
2557
+ const scriptMentions = /(?<![\w-])(--[a-zA-Z][\w-]*)/g;
2558
+ for (const entry of [...inputs.sourceCode, ...inputs.builtCode]) {
2559
+ for (const match of entry.text.matchAll(scriptMentions)) {
2560
+ read.add(match[1] ?? "");
2561
+ }
2562
+ }
2563
+ const problems = [];
2564
+ for (const [name, where] of definitions) {
2565
+ if (!read.has(name)) {
2566
+ problems.push(
2567
+ problem(
2568
+ `${name} is defined but nothing reads it.`,
2569
+ "Remove the custom property, or read it with var().",
2570
+ location("css/unused-custom-property", where)
2571
+ )
2572
+ );
2573
+ }
2574
+ }
2575
+ return problems;
1494
2576
  }
1495
- function execArgs(manager, binary, args) {
1496
- if (manager === "npm") {
1497
- return { executable: "npm", args: ["exec", "--no", "--", binary, ...args] };
2577
+ function insideTheme(node) {
2578
+ for (let parent = node; parent; parent = parent.parent) {
2579
+ if (parent.type === "atrule" && parent.name === "theme") {
2580
+ return true;
2581
+ }
1498
2582
  }
1499
- if (manager === "yarn") {
1500
- return { executable: "yarn", args: ["exec", binary, ...args] };
2583
+ return false;
2584
+ }
2585
+ var motionProperties = /* @__PURE__ */ new Set([
2586
+ "animation",
2587
+ "animation-name",
2588
+ "transition",
2589
+ "transition-property"
2590
+ ]);
2591
+ var stillValue = /^(?:none|0m?s|initial|unset|inherit)$/i;
2592
+ var reducedMotionQuery = (rule, preference) => rule.name === "media" && new RegExp(`prefers-reduced-motion\\s*:\\s*${preference}`, "i").test(
2593
+ rule.params
2594
+ );
2595
+ function insideMedia(node, preference) {
2596
+ for (let parent = node; parent; parent = parent.parent) {
2597
+ if (parent.type === "atrule" && reducedMotionQuery(parent, preference)) {
2598
+ return true;
2599
+ }
1501
2600
  }
1502
- if (manager === "bun") {
1503
- return { executable: "bun", args: ["x", "--no-install", binary, ...args] };
2601
+ return false;
2602
+ }
2603
+ var globalSelector = /^(?:\*|html|:root|body|\*::?before|\*::?after|::?before|::?after)$/;
2604
+ function reducedMotion(source) {
2605
+ let global = false;
2606
+ const stilled = /* @__PURE__ */ new Set();
2607
+ for (const entry of source) {
2608
+ entry.root.walkAtRules("media", (media) => {
2609
+ if (!reducedMotionQuery(media, "reduce")) {
2610
+ return;
2611
+ }
2612
+ media.walkRules((rule) => {
2613
+ let touchesMotion = false;
2614
+ rule.walkDecls((decl) => {
2615
+ if (/^(?:animation|transition)/.test(decl.prop)) {
2616
+ touchesMotion = true;
2617
+ }
2618
+ });
2619
+ if (!touchesMotion) {
2620
+ return;
2621
+ }
2622
+ for (const selector of rule.selectors.map(normaliseSelector)) {
2623
+ stilled.add(selector);
2624
+ if (globalSelector.test(selector)) {
2625
+ global = true;
2626
+ }
2627
+ }
2628
+ });
2629
+ });
1504
2630
  }
1505
- return { executable: "pnpm", args: ["exec", binary, ...args] };
2631
+ if (global) {
2632
+ return [];
2633
+ }
2634
+ const problems = [];
2635
+ for (const entry of source) {
2636
+ entry.root.walkDecls((decl) => {
2637
+ if (!motionProperties.has(decl.prop) || stillValue.test(decl.value.trim())) {
2638
+ return;
2639
+ }
2640
+ const parent = decl.parent;
2641
+ if (insideMedia(parent, "reduce") || insideMedia(parent, "no-preference") || insideTheme(parent) || parent?.type !== "rule") {
2642
+ return;
2643
+ }
2644
+ const selectors = parent.selectors.map(
2645
+ normaliseSelector
2646
+ );
2647
+ if (selectors.every((selector) => stilled.has(selector))) {
2648
+ return;
2649
+ }
2650
+ const line = decl.source?.start?.line;
2651
+ problems.push(
2652
+ problem(
2653
+ `${selectors.join(", ")} sets ${decl.prop} with no prefers-reduced-motion path.`,
2654
+ "Wrap the motion in @media (prefers-reduced-motion: no-preference), or still it in a @media (prefers-reduced-motion: reduce) block.",
2655
+ location("css/reduced-motion", {
2656
+ file: entry.file,
2657
+ ...line ? { line } : {}
2658
+ })
2659
+ )
2660
+ );
2661
+ });
2662
+ }
2663
+ return problems;
1506
2664
  }
1507
- function scriptArgs(manager, name) {
1508
- if (manager === "bun") {
1509
- return { executable: "bun", args: ["run", name] };
2665
+ var classAttribute = /\[\s*class\s*[*^$~|]?=\s*(?:"[^"]*"|'[^']*'|[^\]\s]+)\s*(?:[is]\s*)?\]/gi;
2666
+ function classPatches(source) {
2667
+ const problems = [];
2668
+ for (const entry of source) {
2669
+ entry.root.walkRules((rule) => {
2670
+ for (const match of rule.selector.matchAll(classAttribute)) {
2671
+ const line = rule.source?.start?.line;
2672
+ problems.push(
2673
+ problem(
2674
+ `${match[0]} patches elements by their class attribute.`,
2675
+ "Style the element through a theme token or its own class. Dark mode goes through tokens, not by matching utility classes.",
2676
+ location("css/class-patch", {
2677
+ file: entry.file,
2678
+ ...line ? { line } : {}
2679
+ })
2680
+ )
2681
+ );
2682
+ }
2683
+ });
1510
2684
  }
1511
- if (manager === "npm") {
1512
- return { executable: "npm", args: ["run", name] };
2685
+ return problems;
2686
+ }
2687
+ var colourLiteral = /#[0-9a-f]{3,8}\b|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^()]*\)/gi;
2688
+ var trivialValue = /^(?:0|none|inherit|initial|unset|auto|transparent|currentcolor|0px)$/;
2689
+ function literalTokens(source) {
2690
+ const tokens = /* @__PURE__ */ new Map();
2691
+ for (const entry of source) {
2692
+ entry.root.walkDecls(/^--/, (decl) => {
2693
+ const value = compact(decl.value);
2694
+ if (value.includes("var(") || trivialValue.test(value) || tokens.has(value)) {
2695
+ return;
2696
+ }
2697
+ const parent = decl.parent;
2698
+ const rootLevel = parent?.type === "rule" && parent.selectors.some(
2699
+ (selector) => /^(?::root|html)$/.test(selector.trim())
2700
+ );
2701
+ if (insideTheme(parent) || rootLevel) {
2702
+ tokens.set(value, decl.prop);
2703
+ }
2704
+ });
1513
2705
  }
1514
- return { executable: manager, args: [name] };
2706
+ if (tokens.size === 0) {
2707
+ return [];
2708
+ }
2709
+ const problems = [];
2710
+ for (const entry of source) {
2711
+ entry.root.walkDecls((decl) => {
2712
+ if (decl.prop.startsWith("--") || insideTheme(decl.parent)) {
2713
+ return;
2714
+ }
2715
+ const prop = decl.prop.toLowerCase();
2716
+ const candidates = [];
2717
+ if (/radius$/.test(prop) || /shadow$/.test(prop)) {
2718
+ candidates.push(decl.value);
2719
+ }
2720
+ if (/(?:^|-)(?:color|background|border|outline|fill|stroke|shadow|decoration)/.test(
2721
+ prop
2722
+ ) || prop === "color" || prop === "fill" || prop === "stroke") {
2723
+ candidates.push(...decl.value.match(colourLiteral) ?? []);
2724
+ }
2725
+ for (const candidate of new Set(candidates.map(compact))) {
2726
+ const token = tokens.get(candidate);
2727
+ if (!token || trivialValue.test(candidate)) {
2728
+ continue;
2729
+ }
2730
+ const line = decl.source?.start?.line;
2731
+ problems.push(
2732
+ problem(
2733
+ `${decl.prop}: ${decl.value} restates the token ${token}.`,
2734
+ `Use var(${token}).`,
2735
+ location("css/literal-token", {
2736
+ file: entry.file,
2737
+ ...line ? { line } : {}
2738
+ })
2739
+ )
2740
+ );
2741
+ break;
2742
+ }
2743
+ });
2744
+ }
2745
+ return problems;
1515
2746
  }
1516
2747
 
1517
- // src/build.ts
1518
- async function checkBuild({
2748
+ // src/css.ts
2749
+ async function checkCss({
1519
2750
  cwd,
1520
- packageManager,
1521
2751
  signal
1522
2752
  }) {
1523
2753
  signal?.throwIfAborted();
1524
- const manifest = JSON.parse(
1525
- await readFile7(path7.join(cwd, "package.json"), "utf8")
1526
- );
1527
- const invocation = manifest.dependencies?.["react-router"] ? execArgs(packageManager, "react-router", ["build"]) : manifest.scripts?.build ? scriptArgs(packageManager, "build") : void 0;
1528
- if (!invocation) {
1529
- return skipped(
1530
- "build",
1531
- "No react-router dependency or build script is defined in package.json."
1532
- );
2754
+ const inputs = await readCssInputs(cwd);
2755
+ if (inputs.sourceCss.length === 0 && inputs.builtCss.length === 0) {
2756
+ return skipped("css", "No source or built CSS files were found.");
1533
2757
  }
1534
- const result = await command(
1535
- invocation.executable,
1536
- invocation.args,
1537
- cwd,
1538
- signal
1539
- );
2758
+ const problems = analyseCss(inputs);
1540
2759
  signal?.throwIfAborted();
1541
- if (result.code === 0) {
1542
- return step("build", "passed");
1543
- }
1544
- return step("build", "failed", [
1545
- problem(
1546
- `The project build failed.
1547
- ${usefulTail(result.stdout, result.stderr)}`,
1548
- "Fix the reported build errors and run zappie check again.",
1549
- { rule: "build/failed" }
1550
- )
1551
- ]);
1552
- }
1553
- function usefulTail(stdout, stderr) {
1554
- const lines = `${stdout}
1555
- ${stderr}`.split(/\r?\n/).map((line) => line.trimEnd()).filter(Boolean).slice(-12);
1556
- return lines.length > 0 ? lines.join("\n") : "The build exited without output.";
2760
+ const failed = problems.some(({ rule }) => CSS_ERRORS.has(rule));
2761
+ return step("css", failed ? "failed" : "passed", problems);
1557
2762
  }
1558
2763
 
1559
2764
  // src/lint.ts
1560
2765
  import zappie from "eslint-plugin-zappie";
1561
2766
  import { createRequire } from "module";
1562
- import path8 from "path";
2767
+ import path10 from "path";
1563
2768
  import { pathToFileURL as pathToFileURL2 } from "url";
1564
2769
  async function checkLint({
1565
2770
  cwd,
@@ -1568,12 +2773,12 @@ async function checkLint({
1568
2773
  signal?.throwIfAborted();
1569
2774
  let ESLint;
1570
2775
  try {
1571
- const require2 = createRequire(path8.join(cwd, "package.json"));
2776
+ const require2 = createRequire(path10.join(cwd, "package.json"));
1572
2777
  const loadedEslint = await import(pathToFileURL2(require2.resolve("eslint")).href);
1573
2778
  ESLint = loadedEslint.ESLint;
1574
2779
  } catch (error) {
1575
2780
  return step("lint", "failed", [
1576
- problem(message2(error), "Install ESLint in the project.", {
2781
+ problem(message3(error), "Install ESLint in the project.", {
1577
2782
  rule: "lint/unavailable"
1578
2783
  })
1579
2784
  ]);
@@ -1604,7 +2809,7 @@ async function checkLint({
1604
2809
  } catch (error) {
1605
2810
  return step("lint", "failed", [
1606
2811
  problem(
1607
- message2(error),
2812
+ message3(error),
1608
2813
  "Fix the project ESLint configuration and run lint again.",
1609
2814
  {
1610
2815
  rule: "lint/config"
@@ -1634,7 +2839,9 @@ function recommendedRules() {
1634
2839
  Object.assign(result, config.rules ?? {});
1635
2840
  }
1636
2841
  return Object.fromEntries(
1637
- Object.entries(result).filter(([name]) => name.startsWith("zappie/"))
2842
+ Object.entries(result).filter(
2843
+ ([name]) => name.startsWith("zappie/") || name.startsWith("jsx-a11y/")
2844
+ )
1638
2845
  );
1639
2846
  }
1640
2847
  function level(value) {
@@ -1657,7 +2864,7 @@ async function sourceFile(cwd) {
1657
2864
  for (const candidate of candidates) {
1658
2865
  try {
1659
2866
  await import("fs/promises").then(
1660
- ({ access: access3 }) => access3(path8.join(cwd, candidate))
2867
+ ({ access: access4 }) => access4(path10.join(cwd, candidate))
1661
2868
  );
1662
2869
  return candidate;
1663
2870
  } catch {
@@ -1666,13 +2873,13 @@ async function sourceFile(cwd) {
1666
2873
  }
1667
2874
  return "eslint.config.mjs";
1668
2875
  }
1669
- function message2(error) {
2876
+ function message3(error) {
1670
2877
  return error instanceof Error ? error.message : String(error);
1671
2878
  }
1672
2879
 
1673
2880
  // src/readiness.ts
1674
- import { readFile as readFile8 } from "fs/promises";
1675
- import path9 from "path";
2881
+ import { readFile as readFile10 } from "fs/promises";
2882
+ import path11 from "path";
1676
2883
  import { pathToFileURL as pathToFileURL3 } from "url";
1677
2884
  async function checkReadiness({
1678
2885
  cwd,
@@ -1690,9 +2897,9 @@ async function checkReadiness({
1690
2897
  )
1691
2898
  ]);
1692
2899
  }
1693
- const file = path9.join(cwd, "react-router.config.ts");
2900
+ const file = path11.join(cwd, "react-router.config.ts");
1694
2901
  try {
1695
- await readFile8(file, "utf8");
2902
+ await readFile10(file, "utf8");
1696
2903
  const version = Date.now();
1697
2904
  const config = await import(`${pathToFileURL3(file).href}?readiness=${version}`);
1698
2905
  if (config.default?.routeDiscovery?.mode === "initial") {
@@ -1701,7 +2908,7 @@ async function checkReadiness({
1701
2908
  } catch (error) {
1702
2909
  return step("readiness", "failed", [
1703
2910
  problem(
1704
- message3(error),
2911
+ message4(error),
1705
2912
  'Create react-router.config.ts and set routeDiscovery: { mode: "initial" }.',
1706
2913
  {
1707
2914
  rule: "readiness/route-discovery",
@@ -1718,13 +2925,15 @@ async function checkReadiness({
1718
2925
  )
1719
2926
  ]);
1720
2927
  }
1721
- function message3(error) {
2928
+ function message4(error) {
1722
2929
  return error instanceof Error ? error.message : String(error);
1723
2930
  }
1724
2931
 
1725
2932
  // src/secrets.ts
1726
- import { lstat, readFile as readFile9, readdir as readdir2 } from "fs/promises";
1727
- import path10 from "path";
2933
+ import { execFile } from "child_process";
2934
+ import { lstat as lstat2, readFile as readFile11, readdir as readdir3 } from "fs/promises";
2935
+ import path12 from "path";
2936
+ import { promisify } from "util";
1728
2937
 
1729
2938
  // src/secret-patterns.ts
1730
2939
  var FIX = "Move the value to a server environment variable and rotate it.";
@@ -1765,6 +2974,35 @@ function secretMatches(source, envFile) {
1765
2974
  fix: FIX,
1766
2975
  severity: "error"
1767
2976
  }));
2977
+ const gitleaksDefaults = [
2978
+ [/\bghp_[A-Za-z0-9]{36}\b/g, "github-pat", "GitHub personal access token"],
2979
+ [
2980
+ /\bgithub_pat_[A-Za-z0-9_]{82}\b/g,
2981
+ "github-fine-grained-pat",
2982
+ "GitHub fine-grained access token"
2983
+ ],
2984
+ [
2985
+ /\bglpat-[A-Za-z0-9_-]{20,}\b/g,
2986
+ "gitlab-pat",
2987
+ "GitLab personal access token"
2988
+ ],
2989
+ [/\bnpm_[A-Za-z0-9]{36}\b/g, "npm-token", "npm access token"],
2990
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, "slack-token", "Slack token"],
2991
+ [/\bAIza[0-9A-Za-z_-]{35}\b/g, "google-api-key", "Google API key"],
2992
+ [
2993
+ /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g,
2994
+ "sendgrid-api-key",
2995
+ "SendGrid API key"
2996
+ ]
2997
+ ];
2998
+ for (const [pattern, rule, label] of gitleaksDefaults) {
2999
+ add(matches2, source, pattern, () => ({
3000
+ rule: `secrets/${rule}`,
3001
+ message: `Detected a ${label}.`,
3002
+ fix: FIX,
3003
+ severity: "error"
3004
+ }));
3005
+ }
1768
3006
  const occupied = matches2.map(({ index }) => index);
1769
3007
  const assignment = /\b([A-Za-z_$][\w$]*(?:secret|token|password|api[_-]?key)[\w$]*)\s*[:=]\s*(["'])([^"'\r\n]{24,})\2/gi;
1770
3008
  for (const found of source.matchAll(assignment)) {
@@ -1802,21 +3040,31 @@ function entropy(value) {
1802
3040
 
1803
3041
  // src/secrets.ts
1804
3042
  var SECRET_FILE_SIZE_CAP = 1024 * 1024;
1805
- async function scanSecrets(dir) {
3043
+ var execFileAsync = promisify(execFile);
3044
+ var lockfile = /(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/;
3045
+ async function scanSecrets(dir, options = {}) {
1806
3046
  const findings = [];
1807
3047
  for (const file of await filesToScan(dir)) {
1808
- const info = await lstat(file);
3048
+ let info;
3049
+ try {
3050
+ info = await lstat2(file);
3051
+ } catch (error) {
3052
+ if (missing2(error)) {
3053
+ continue;
3054
+ }
3055
+ throw error;
3056
+ }
1809
3057
  if (!info.isFile() || info.size > SECRET_FILE_SIZE_CAP) {
1810
3058
  continue;
1811
3059
  }
1812
- const contents = await readFile9(file);
3060
+ const contents = await readFile11(file);
1813
3061
  if (contents.includes(0)) {
1814
3062
  continue;
1815
3063
  }
1816
3064
  const source = contents.toString("utf8");
1817
3065
  for (const match of secretMatches(
1818
3066
  source,
1819
- path10.basename(file).startsWith(".env")
3067
+ path12.basename(file).startsWith(".env")
1820
3068
  )) {
1821
3069
  findings.push({
1822
3070
  rule: match.rule,
@@ -1828,14 +3076,21 @@ async function scanSecrets(dir) {
1828
3076
  });
1829
3077
  }
1830
3078
  }
3079
+ if (options.history) {
3080
+ findings.push(...await scanHistory(dir));
3081
+ }
1831
3082
  return findings;
1832
3083
  }
1833
3084
  async function checkSecrets({
1834
3085
  cwd,
3086
+ history,
1835
3087
  signal
1836
3088
  }) {
1837
3089
  signal?.throwIfAborted();
1838
- const findings = await scanSecrets(cwd);
3090
+ const findings = await scanSecrets(
3091
+ cwd,
3092
+ history === void 0 ? {} : { history }
3093
+ );
1839
3094
  signal?.throwIfAborted();
1840
3095
  const failed = findings.some(({ severity }) => severity === "error");
1841
3096
  return step(
@@ -1845,45 +3100,116 @@ async function checkSecrets({
1845
3100
  );
1846
3101
  }
1847
3102
  async function filesToScan(cwd) {
3103
+ const files = await trackedFiles(cwd);
3104
+ for (const directory of ["build/client", "build/server", "workers"]) {
3105
+ await collect3(path12.join(cwd, directory), files);
3106
+ }
3107
+ return [...new Set(files)].filter((file) => !lockfile.test(relative(cwd, file))).sort();
3108
+ }
3109
+ async function trackedFiles(cwd) {
3110
+ try {
3111
+ const { stdout } = await execFileAsync("git", ["ls-files", "-z"], {
3112
+ cwd,
3113
+ encoding: "utf8",
3114
+ maxBuffer: 32 * 1024 * 1024
3115
+ });
3116
+ const files = stdout.split("\0").filter(Boolean).map((file) => path12.join(cwd, file));
3117
+ return files.length > 0 ? files : fallbackFiles(cwd);
3118
+ } catch {
3119
+ return fallbackFiles(cwd);
3120
+ }
3121
+ }
3122
+ async function fallbackFiles(cwd) {
1848
3123
  const files = [];
1849
3124
  for (const directory of ["app", "public"]) {
1850
- await collect2(path10.join(cwd, directory), files);
3125
+ await collect3(path12.join(cwd, directory), files);
1851
3126
  }
1852
- await collect2(
1853
- path10.join(cwd, "build/client"),
1854
- files,
1855
- (file) => file.endsWith(".js")
1856
- );
1857
3127
  try {
1858
- for (const entry of await readdir2(cwd, { withFileTypes: true })) {
3128
+ for (const entry of await readdir3(cwd, { withFileTypes: true })) {
1859
3129
  if (entry.isFile() && rootConfig(entry.name)) {
1860
- files.push(path10.join(cwd, entry.name));
3130
+ files.push(path12.join(cwd, entry.name));
1861
3131
  }
1862
3132
  }
1863
3133
  } catch {
1864
- return files.sort();
3134
+ return files;
3135
+ }
3136
+ return files;
3137
+ }
3138
+ async function scanHistory(cwd) {
3139
+ try {
3140
+ const { stdout } = await execFileAsync(
3141
+ "git",
3142
+ [
3143
+ "log",
3144
+ "--all",
3145
+ "--format=commit:%H",
3146
+ "-p",
3147
+ "--no-ext-diff",
3148
+ "--",
3149
+ ".",
3150
+ ":(exclude)package-lock.json",
3151
+ ":(exclude)pnpm-lock.yaml",
3152
+ ":(exclude)yarn.lock",
3153
+ ":(exclude)bun.lock",
3154
+ ":(exclude)bun.lockb"
3155
+ ],
3156
+ { cwd, encoding: "utf8", maxBuffer: 128 * 1024 * 1024 }
3157
+ );
3158
+ return secretMatches(stdout, false).map((match) => {
3159
+ const location2 = historyLocation(stdout, match.index);
3160
+ return {
3161
+ rule: match.rule,
3162
+ file: location2 ?? "git history",
3163
+ line: lineAt(stdout, match.index),
3164
+ message: match.message,
3165
+ fix: match.fix,
3166
+ severity: match.severity
3167
+ };
3168
+ });
3169
+ } catch {
3170
+ return [
3171
+ {
3172
+ rule: "secrets/history-unavailable",
3173
+ file: "git history",
3174
+ line: 1,
3175
+ message: "Could not scan Git history for secrets.",
3176
+ fix: "Check Git access and run zappie check --history again.",
3177
+ severity: "warning"
3178
+ }
3179
+ ];
1865
3180
  }
1866
- return [...new Set(files)].sort();
1867
3181
  }
1868
- async function collect2(directory, files, accept = () => true) {
3182
+ function historyLocation(source, index) {
3183
+ const prefix = source.slice(0, index);
3184
+ const commit = [...prefix.matchAll(/^commit:([0-9a-f]+)$/gm)].at(-1)?.[1];
3185
+ const changedPaths = [
3186
+ ...prefix.matchAll(/^(?:\+\+\+ b\/|--- a\/)(.+)$/gm)
3187
+ ].map((match) => match[1]);
3188
+ const file = changedPaths.at(-1);
3189
+ return commit && file ? `${commit}:${file}` : void 0;
3190
+ }
3191
+ async function collect3(directory, files, accept = () => true) {
1869
3192
  try {
1870
- for (const entry of await readdir2(directory, { withFileTypes: true })) {
3193
+ for (const entry of await readdir3(directory, { withFileTypes: true })) {
1871
3194
  if (entry.isSymbolicLink()) {
1872
3195
  continue;
1873
3196
  }
1874
- const file = path10.join(directory, entry.name);
3197
+ const file = path12.join(directory, entry.name);
1875
3198
  if (entry.isDirectory()) {
1876
- await collect2(file, files, accept);
3199
+ await collect3(file, files, accept);
1877
3200
  } else if (entry.isFile() && accept(file)) {
1878
3201
  files.push(file);
1879
3202
  }
1880
3203
  }
1881
3204
  } catch (error) {
1882
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
3205
+ if (!missing2(error)) {
1883
3206
  throw error;
1884
3207
  }
1885
3208
  }
1886
3209
  }
3210
+ function missing2(error) {
3211
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
3212
+ }
1887
3213
  function rootConfig(name) {
1888
3214
  return name === "package.json" || name === "wrangler.json" || name === "wrangler.jsonc" || /(?:^|\.)config\.(?:[cm]?[jt]s|json)$/.test(name);
1889
3215
  }
@@ -1892,13 +3218,13 @@ function lineAt(source, index) {
1892
3218
  }
1893
3219
 
1894
3220
  // src/test-step.ts
1895
- import path11 from "path";
3221
+ import path13 from "path";
1896
3222
  async function checkTests({
1897
3223
  cwd,
1898
3224
  packageManager,
1899
3225
  signal
1900
3226
  }) {
1901
- const manifest = await json(path11.join(cwd, "package.json"));
3227
+ const manifest = await json(path13.join(cwd, "package.json"));
1902
3228
  if (!manifest.scripts?.test) {
1903
3229
  return skipped("test", "No test script is defined in package.json.");
1904
3230
  }
@@ -1921,11 +3247,11 @@ async function checkTests({
1921
3247
  }
1922
3248
 
1923
3249
  // src/types-step.ts
1924
- import path12 from "path";
3250
+ import path14 from "path";
1925
3251
  async function checkTypes(options) {
1926
3252
  const { cwd, packageManager, signal } = options;
1927
3253
  signal?.throwIfAborted();
1928
- const manifest = await json(path12.join(cwd, "package.json"));
3254
+ const manifest = await json(path14.join(cwd, "package.json"));
1929
3255
  if (manifest.dependencies?.["react-router"]) {
1930
3256
  const generated = execArgs(packageManager, "react-router", ["typegen"]);
1931
3257
  const result2 = await command(
@@ -1979,13 +3305,15 @@ function outputProblems(output) {
1979
3305
  var runners = [
1980
3306
  { name: "config", run: checkConfig },
1981
3307
  { name: "allowlist", run: checkAllowlist },
3308
+ { name: "audit", run: checkAudit },
1982
3309
  { name: "lint", run: checkLint },
1983
3310
  { name: "types", run: checkTypes },
1984
3311
  { name: "test", run: checkTests },
1985
3312
  { name: "build", run: checkBuild },
1986
3313
  { name: "secrets", run: checkSecrets },
1987
3314
  { name: "readiness", run: checkReadiness },
1988
- { name: "budgets", run: checkBudgets }
3315
+ { name: "budgets", run: checkBudgets },
3316
+ { name: "css", run: checkCss }
1989
3317
  ];
1990
3318
  async function runCheck(options) {
1991
3319
  const steps = [];
@@ -1998,13 +3326,13 @@ async function runCheck(options) {
1998
3326
  options.signal?.throwIfAborted();
1999
3327
  checked = step(runner.name, "failed", [
2000
3328
  problem(
2001
- message4(error),
3329
+ message5(error),
2002
3330
  "Fix the step setup and run zappie check again."
2003
3331
  )
2004
3332
  ]);
2005
3333
  }
2006
3334
  steps.push(checked);
2007
- if (checked.result === "failed" && !options.all) {
3335
+ if (checked.result === "failed" && !options.all && runner.name !== "audit") {
2008
3336
  break;
2009
3337
  }
2010
3338
  }
@@ -2013,22 +3341,28 @@ async function runCheck(options) {
2013
3341
  steps
2014
3342
  };
2015
3343
  }
2016
- function message4(error) {
3344
+ function message5(error) {
2017
3345
  return error instanceof Error ? error.message : String(error);
2018
3346
  }
2019
3347
  export {
2020
3348
  BUILTIN_ALLOWLIST_SOURCE,
3349
+ CSS_ERRORS,
2021
3350
  SECRET_FILE_SIZE_CAP,
3351
+ analyseCss,
2022
3352
  checkAllowlist,
3353
+ checkAudit,
2023
3354
  checkBudgets,
2024
3355
  checkBuild,
2025
3356
  checkConfig,
3357
+ checkCss,
2026
3358
  checkLint,
2027
3359
  checkReadiness,
2028
3360
  checkSecrets,
2029
3361
  checkTests,
2030
3362
  checkTypes,
3363
+ evaluateAudit,
2031
3364
  loadConfig,
3365
+ parseAuditOutput,
2032
3366
  runCheck,
2033
3367
  scanSecrets,
2034
3368
  supportsTypeScriptImport