@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/CHANGELOG.md +26 -0
- package/README.md +40 -1
- package/dist/index.d.ts +64 -4
- package/dist/index.js +1521 -187
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/schema/check-result.v1.json +32 -2
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,
|
|
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:
|
|
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,
|
|
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:
|
|
85
|
+
path: path15,
|
|
86
86
|
code: "invalid_type",
|
|
87
|
-
message: `Expected an object. Set ${
|
|
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,
|
|
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
|
|
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
|
-
`${
|
|
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: `${
|
|
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 = (
|
|
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,
|
|
288
|
+
function requiredSlug(value, path15, error) {
|
|
288
289
|
if (!nonblank(value)) {
|
|
289
290
|
error(
|
|
290
|
-
|
|
291
|
+
path15,
|
|
291
292
|
value === void 0 ? "missing_required" : "invalid_type",
|
|
292
|
-
`Expected a non-empty storefront slug. Set ${
|
|
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,
|
|
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
|
-
|
|
318
|
+
path15,
|
|
318
319
|
"invalid_type",
|
|
319
|
-
`Expected an array of strings. Set ${
|
|
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
|
-
|
|
326
|
+
path15,
|
|
326
327
|
"invalid_format",
|
|
327
|
-
`Expected at least one value. Add a value to ${
|
|
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
|
-
`${
|
|
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
|
-
|
|
342
|
+
path15,
|
|
342
343
|
"duplicate",
|
|
343
|
-
`Expected unique values. Remove duplicates from ${
|
|
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((
|
|
351
|
-
if (!
|
|
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 = (
|
|
855
|
-
const keys = (obj, allowed,
|
|
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(
|
|
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
|
|
920
|
+
const path15 = `${group}.${key}`;
|
|
920
921
|
if (!includes(TOKEN_NAMES, key)) {
|
|
921
|
-
issue(
|
|
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(
|
|
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
|
|
950
|
+
const path15 = `fonts[${i}]`;
|
|
950
951
|
if (!record2(font)) {
|
|
951
|
-
issue(
|
|
952
|
+
issue(path15, "invalid_font", "Expected a font object");
|
|
952
953
|
return;
|
|
953
954
|
}
|
|
954
|
-
keys(font, ["family", "source", "url", "weights", "role"],
|
|
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
|
-
`${
|
|
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(`${
|
|
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(`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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(
|
|
1093
|
-
return { ...details, message:
|
|
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,
|
|
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
|
|
1323
|
-
import
|
|
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
|
|
1327
|
-
import
|
|
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 =
|
|
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:
|
|
1336
|
-
bytes: gzipSync(await
|
|
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 =
|
|
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
|
|
1356
|
-
import
|
|
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
|
|
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 =
|
|
1883
|
+
const manifestFile = path7.join(cwd, "build/client/.vite/manifest.json");
|
|
1405
1884
|
try {
|
|
1406
|
-
await
|
|
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
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
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/
|
|
1518
|
-
async function
|
|
2748
|
+
// src/css.ts
|
|
2749
|
+
async function checkCss({
|
|
1519
2750
|
cwd,
|
|
1520
|
-
packageManager,
|
|
1521
2751
|
signal
|
|
1522
2752
|
}) {
|
|
1523
2753
|
signal?.throwIfAborted();
|
|
1524
|
-
const
|
|
1525
|
-
|
|
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
|
|
1535
|
-
invocation.executable,
|
|
1536
|
-
invocation.args,
|
|
1537
|
-
cwd,
|
|
1538
|
-
signal
|
|
1539
|
-
);
|
|
2758
|
+
const problems = analyseCss(inputs);
|
|
1540
2759
|
signal?.throwIfAborted();
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
1675
|
-
import
|
|
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 =
|
|
2900
|
+
const file = path11.join(cwd, "react-router.config.ts");
|
|
1694
2901
|
try {
|
|
1695
|
-
await
|
|
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
|
-
|
|
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
|
|
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 {
|
|
1727
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
3128
|
+
for (const entry of await readdir3(cwd, { withFileTypes: true })) {
|
|
1859
3129
|
if (entry.isFile() && rootConfig(entry.name)) {
|
|
1860
|
-
files.push(
|
|
3130
|
+
files.push(path12.join(cwd, entry.name));
|
|
1861
3131
|
}
|
|
1862
3132
|
}
|
|
1863
3133
|
} catch {
|
|
1864
|
-
return files
|
|
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
|
-
|
|
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
|
|
3193
|
+
for (const entry of await readdir3(directory, { withFileTypes: true })) {
|
|
1871
3194
|
if (entry.isSymbolicLink()) {
|
|
1872
3195
|
continue;
|
|
1873
3196
|
}
|
|
1874
|
-
const file =
|
|
3197
|
+
const file = path12.join(directory, entry.name);
|
|
1875
3198
|
if (entry.isDirectory()) {
|
|
1876
|
-
await
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|