@fairfox/polly 0.40.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/polly-ui/registry.d.ts +16 -0
- package/dist/src/polly-ui/registry.generated.d.ts +20 -0
- package/dist/tools/quality/src/attest.d.ts +55 -0
- package/dist/tools/quality/src/cache.d.ts +34 -0
- package/dist/tools/quality/src/cli.js +1738 -2
- package/dist/tools/quality/src/cli.js.map +13 -4
- package/dist/tools/quality/src/config.d.ts +18 -0
- package/dist/tools/quality/src/host.d.ts +46 -0
- package/dist/tools/quality/src/index.d.ts +7 -0
- package/dist/tools/quality/src/index.js +1637 -1
- package/dist/tools/quality/src/index.js.map +13 -4
- package/dist/tools/quality/src/plugins/core-checks.d.ts +20 -0
- package/dist/tools/quality/src/plugins/core.d.ts +18 -0
- package/dist/tools/quality/src/plugins/extra-checks.d.ts +14 -0
- package/dist/tools/quality/src/plugins/import-checks.d.ts +16 -0
- package/dist/tools/quality/src/plugins/polly-ui.d.ts +31 -0
- package/dist/tools/quality/src/types.d.ts +104 -0
- package/dist/tools/verify/src/cli.js +324 -226
- package/dist/tools/verify/src/cli.js.map +6 -5
- package/package.json +8 -2
|
@@ -1053,12 +1053,1646 @@ async function checkGitignoreCoversAllowlist(options) {
|
|
|
1053
1053
|
}
|
|
1054
1054
|
};
|
|
1055
1055
|
}
|
|
1056
|
+
// tools/quality/src/attest.ts
|
|
1057
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1058
|
+
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
1059
|
+
import { join as join5 } from "node:path";
|
|
1060
|
+
|
|
1061
|
+
// tools/quality/src/cache.ts
|
|
1062
|
+
import { createHash } from "node:crypto";
|
|
1063
|
+
import { mkdir, readFile as readFile2, rename, writeFile } from "node:fs/promises";
|
|
1064
|
+
import { dirname, isAbsolute, join as join4, resolve } from "node:path";
|
|
1065
|
+
var ABSENT_MARKER = "<absent>";
|
|
1066
|
+
async function hashFile(path) {
|
|
1067
|
+
try {
|
|
1068
|
+
const buf = await readFile2(path);
|
|
1069
|
+
const h = createHash("sha256");
|
|
1070
|
+
h.update(buf);
|
|
1071
|
+
return h.digest("hex");
|
|
1072
|
+
} catch {
|
|
1073
|
+
return ABSENT_MARKER;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async function computeInputsHash(inputs, rootDir) {
|
|
1077
|
+
const filePairs = [];
|
|
1078
|
+
for (const f of inputs.files) {
|
|
1079
|
+
const abs = isAbsolute(f) ? f : resolve(rootDir, f);
|
|
1080
|
+
const hash = await hashFile(abs);
|
|
1081
|
+
const rel = abs.startsWith(`${rootDir}/`) ? abs.slice(rootDir.length + 1) : abs;
|
|
1082
|
+
filePairs.push({ path: rel, hash });
|
|
1083
|
+
}
|
|
1084
|
+
filePairs.sort((a, b) => a.path.localeCompare(b.path));
|
|
1085
|
+
const extras = inputs.extras ?? {};
|
|
1086
|
+
const extrasPairs = Object.keys(extras).sort().map((k) => ({ key: k, value: extras[k] ?? "" }));
|
|
1087
|
+
const payload = JSON.stringify({ files: filePairs, extras: extrasPairs });
|
|
1088
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
1089
|
+
}
|
|
1090
|
+
function cachePath(rootDir, checkId) {
|
|
1091
|
+
const safe = checkId.replace(/:/g, "__");
|
|
1092
|
+
return join4(rootDir, ".cache", "polly-quality", `${safe}.json`);
|
|
1093
|
+
}
|
|
1094
|
+
async function getCachedOutcome(rootDir, checkId, hash) {
|
|
1095
|
+
const path = cachePath(rootDir, checkId);
|
|
1096
|
+
let raw;
|
|
1097
|
+
try {
|
|
1098
|
+
raw = await readFile2(path, "utf8");
|
|
1099
|
+
} catch {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
let parsed;
|
|
1103
|
+
try {
|
|
1104
|
+
parsed = JSON.parse(raw);
|
|
1105
|
+
} catch {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
if (parsed.hash !== hash)
|
|
1109
|
+
return null;
|
|
1110
|
+
if (typeof parsed.outcome?.ok !== "boolean" || !Array.isArray(parsed.outcome?.messages)) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
return parsed.outcome;
|
|
1114
|
+
}
|
|
1115
|
+
async function setCachedOutcome(rootDir, checkId, hash, outcome) {
|
|
1116
|
+
const path = cachePath(rootDir, checkId);
|
|
1117
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1118
|
+
const entry = { hash, outcome, writtenAt: new Date().toISOString() };
|
|
1119
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
1120
|
+
await writeFile(tmp, JSON.stringify(entry, null, 2));
|
|
1121
|
+
await rename(tmp, path);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// tools/quality/src/host.ts
|
|
1125
|
+
function registerPlugins(plugins) {
|
|
1126
|
+
const registry = new Map;
|
|
1127
|
+
const pluginNames = new Set;
|
|
1128
|
+
for (const plugin of plugins) {
|
|
1129
|
+
if (pluginNames.has(plugin.name)) {
|
|
1130
|
+
throw new Error(`Quality plugin name collision: "${plugin.name}" registered twice`);
|
|
1131
|
+
}
|
|
1132
|
+
pluginNames.add(plugin.name);
|
|
1133
|
+
for (const check of plugin.checks) {
|
|
1134
|
+
if (!check.id.startsWith(`${plugin.name}:`)) {
|
|
1135
|
+
throw new Error(`Check "${check.id}" is registered under plugin "${plugin.name}" but its id does not begin with "${plugin.name}:"`);
|
|
1136
|
+
}
|
|
1137
|
+
if (registry.has(check.id)) {
|
|
1138
|
+
const prior = registry.get(check.id)?.plugin.name ?? "<unknown>";
|
|
1139
|
+
throw new Error(`Check id collision: "${check.id}" registered by plugins "${prior}" and "${plugin.name}"`);
|
|
1140
|
+
}
|
|
1141
|
+
registry.set(check.id, { check, plugin });
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return registry;
|
|
1145
|
+
}
|
|
1146
|
+
function validateRunConfig(registry, runConfig) {
|
|
1147
|
+
const errors = [];
|
|
1148
|
+
const checks = runConfig.checks ?? {};
|
|
1149
|
+
for (const [id, cfg] of Object.entries(checks)) {
|
|
1150
|
+
const entry = registry.get(id);
|
|
1151
|
+
if (!entry) {
|
|
1152
|
+
errors.push(`Configuration references unknown check id "${id}"`);
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
if (!entry.check.validate)
|
|
1156
|
+
continue;
|
|
1157
|
+
const result = entry.check.validate(cfg);
|
|
1158
|
+
if (result === null)
|
|
1159
|
+
continue;
|
|
1160
|
+
for (const msg of result) {
|
|
1161
|
+
errors.push(`[${id}] ${msg}`);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return errors;
|
|
1165
|
+
}
|
|
1166
|
+
async function resolveCacheHash(check, config, opts) {
|
|
1167
|
+
if (opts.noCache || !check.filesRead)
|
|
1168
|
+
return null;
|
|
1169
|
+
try {
|
|
1170
|
+
const files = await check.filesRead(config, opts.rootDir);
|
|
1171
|
+
const extras = check.cacheKeyExtras?.(config);
|
|
1172
|
+
return computeInputsHash({ files, ...extras ? { extras } : {} }, opts.rootDir);
|
|
1173
|
+
} catch {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
async function executeBody(check, config, opts) {
|
|
1178
|
+
try {
|
|
1179
|
+
return await check.run({
|
|
1180
|
+
rootDir: opts.rootDir,
|
|
1181
|
+
config,
|
|
1182
|
+
...opts.signal ? { signal: opts.signal } : {}
|
|
1183
|
+
});
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
async function runOne(entry, config, opts) {
|
|
1189
|
+
const { check } = entry;
|
|
1190
|
+
const start = Date.now();
|
|
1191
|
+
const cacheHash = await resolveCacheHash(check, config, opts);
|
|
1192
|
+
if (cacheHash) {
|
|
1193
|
+
const cached = await getCachedOutcome(opts.rootDir, check.id, cacheHash);
|
|
1194
|
+
if (cached) {
|
|
1195
|
+
return {
|
|
1196
|
+
id: check.id,
|
|
1197
|
+
ok: cached.ok,
|
|
1198
|
+
durationMs: Date.now() - start,
|
|
1199
|
+
cached: true,
|
|
1200
|
+
messages: cached.messages
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const result = await executeBody(check, config, opts);
|
|
1205
|
+
if ("error" in result) {
|
|
1206
|
+
return {
|
|
1207
|
+
id: check.id,
|
|
1208
|
+
ok: false,
|
|
1209
|
+
durationMs: Date.now() - start,
|
|
1210
|
+
cached: false,
|
|
1211
|
+
messages: [],
|
|
1212
|
+
error: result.error
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
if (cacheHash && result.ok) {
|
|
1216
|
+
try {
|
|
1217
|
+
await setCachedOutcome(opts.rootDir, check.id, cacheHash, result);
|
|
1218
|
+
} catch {}
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
id: check.id,
|
|
1222
|
+
ok: result.ok,
|
|
1223
|
+
durationMs: Date.now() - start,
|
|
1224
|
+
cached: false,
|
|
1225
|
+
messages: result.messages
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
async function runWithConcurrency(items, limit, worker) {
|
|
1229
|
+
if (items.length === 0)
|
|
1230
|
+
return [];
|
|
1231
|
+
const results = new Array(items.length);
|
|
1232
|
+
let cursor = 0;
|
|
1233
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
1234
|
+
while (true) {
|
|
1235
|
+
const i = cursor++;
|
|
1236
|
+
if (i >= items.length)
|
|
1237
|
+
return;
|
|
1238
|
+
const item = items[i];
|
|
1239
|
+
if (item === undefined)
|
|
1240
|
+
return;
|
|
1241
|
+
results[i] = await worker(item);
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
await Promise.all(workers);
|
|
1245
|
+
return results;
|
|
1246
|
+
}
|
|
1247
|
+
async function runChecks(registry, runConfig, ids, opts) {
|
|
1248
|
+
const start = Date.now();
|
|
1249
|
+
const targets = [];
|
|
1250
|
+
if (ids === undefined) {
|
|
1251
|
+
targets.push(...registry.values());
|
|
1252
|
+
} else {
|
|
1253
|
+
for (const id of ids) {
|
|
1254
|
+
const entry = registry.get(id);
|
|
1255
|
+
if (!entry) {
|
|
1256
|
+
throw new Error(`Unknown check id: "${id}"`);
|
|
1257
|
+
}
|
|
1258
|
+
targets.push(entry);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const checks = runConfig.checks ?? {};
|
|
1262
|
+
const limit = opts.concurrency ?? targets.length;
|
|
1263
|
+
const results = await runWithConcurrency(targets, limit, (entry) => runOne(entry, checks[entry.check.id], opts));
|
|
1264
|
+
return {
|
|
1265
|
+
ok: results.every((r) => r.ok),
|
|
1266
|
+
results,
|
|
1267
|
+
totalDurationMs: Date.now() - start
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
function listChecks(registry) {
|
|
1271
|
+
return Array.from(registry.values()).map(({ check, plugin }) => ({
|
|
1272
|
+
id: check.id,
|
|
1273
|
+
description: check.description,
|
|
1274
|
+
plugin: plugin.name
|
|
1275
|
+
})).sort((a, b) => a.id.localeCompare(b.id));
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// tools/quality/src/attest.ts
|
|
1279
|
+
async function spawnText(args, cwd) {
|
|
1280
|
+
const proc = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
1281
|
+
const [stdout, stderr] = await Promise.all([
|
|
1282
|
+
new Response(proc.stdout).text(),
|
|
1283
|
+
new Response(proc.stderr).text()
|
|
1284
|
+
]);
|
|
1285
|
+
await proc.exited;
|
|
1286
|
+
return { ok: proc.exitCode === 0, out: `${stdout}${stderr}` };
|
|
1287
|
+
}
|
|
1288
|
+
async function workingTreeIsClean(rootDir) {
|
|
1289
|
+
const r = await spawnText(["git", "status", "--porcelain"], rootDir);
|
|
1290
|
+
if (!r.ok)
|
|
1291
|
+
return false;
|
|
1292
|
+
return r.out.trim().length === 0;
|
|
1293
|
+
}
|
|
1294
|
+
async function currentCommit(rootDir) {
|
|
1295
|
+
const r = await spawnText(["git", "rev-parse", "HEAD"], rootDir);
|
|
1296
|
+
if (!r.ok)
|
|
1297
|
+
return null;
|
|
1298
|
+
const sha = r.out.trim();
|
|
1299
|
+
if (!/^[0-9a-f]{40}$/.test(sha))
|
|
1300
|
+
return null;
|
|
1301
|
+
return sha;
|
|
1302
|
+
}
|
|
1303
|
+
async function ghUser(rootDir) {
|
|
1304
|
+
const r = await spawnText(["gh", "api", "user", "--jq", ".login"], rootDir);
|
|
1305
|
+
if (!r.ok)
|
|
1306
|
+
return null;
|
|
1307
|
+
const login = r.out.trim();
|
|
1308
|
+
return login.length > 0 ? login : null;
|
|
1309
|
+
}
|
|
1310
|
+
async function ghRepoSlug(rootDir) {
|
|
1311
|
+
const r = await spawnText(["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], rootDir);
|
|
1312
|
+
if (!r.ok)
|
|
1313
|
+
return null;
|
|
1314
|
+
const slug = r.out.trim();
|
|
1315
|
+
if (!slug.includes("/"))
|
|
1316
|
+
return null;
|
|
1317
|
+
return slug;
|
|
1318
|
+
}
|
|
1319
|
+
function summariseRunReport(report) {
|
|
1320
|
+
const passed = report.results.filter((r) => r.ok).length;
|
|
1321
|
+
const total = report.results.length;
|
|
1322
|
+
return `${passed}/${total} checks passed`;
|
|
1323
|
+
}
|
|
1324
|
+
function digestRun(sha, results) {
|
|
1325
|
+
const stable = results.slice().sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, ok: r.ok, messages: r.messages }));
|
|
1326
|
+
const payload = JSON.stringify({ sha, results: stable });
|
|
1327
|
+
return createHash2("sha256").update(payload).digest("hex");
|
|
1328
|
+
}
|
|
1329
|
+
function attestCachePath(rootDir, sha) {
|
|
1330
|
+
return join5(rootDir, ".cache", "polly-quality", "attest", `${sha}.json`);
|
|
1331
|
+
}
|
|
1332
|
+
async function readPriorAttestation(rootDir, sha) {
|
|
1333
|
+
try {
|
|
1334
|
+
const raw = await readFile3(attestCachePath(rootDir, sha), "utf8");
|
|
1335
|
+
const parsed = JSON.parse(raw);
|
|
1336
|
+
return parsed.digest ?? null;
|
|
1337
|
+
} catch {
|
|
1338
|
+
return null;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
async function writeAttestation(rootDir, sha, context, digest) {
|
|
1342
|
+
const path = attestCachePath(rootDir, sha);
|
|
1343
|
+
await mkdir2(join5(path, ".."), { recursive: true });
|
|
1344
|
+
const entry = {
|
|
1345
|
+
sha,
|
|
1346
|
+
context,
|
|
1347
|
+
digest,
|
|
1348
|
+
attestedAt: new Date().toISOString()
|
|
1349
|
+
};
|
|
1350
|
+
await writeFile2(path, JSON.stringify(entry, null, 2));
|
|
1351
|
+
}
|
|
1352
|
+
async function postCommitStatus(rootDir, slug, sha, context, description) {
|
|
1353
|
+
const r = await spawnText([
|
|
1354
|
+
"gh",
|
|
1355
|
+
"api",
|
|
1356
|
+
"-X",
|
|
1357
|
+
"POST",
|
|
1358
|
+
`repos/${slug}/statuses/${sha}`,
|
|
1359
|
+
"-f",
|
|
1360
|
+
"state=success",
|
|
1361
|
+
"-f",
|
|
1362
|
+
`context=${context}`,
|
|
1363
|
+
"-f",
|
|
1364
|
+
`description=${description}`
|
|
1365
|
+
], rootDir);
|
|
1366
|
+
return { ok: r.ok, output: r.out };
|
|
1367
|
+
}
|
|
1368
|
+
async function preflightOk(rootDir) {
|
|
1369
|
+
if (!await workingTreeIsClean(rootDir)) {
|
|
1370
|
+
return { ok: false, reason: "Working tree is dirty; commit or stash before attesting." };
|
|
1371
|
+
}
|
|
1372
|
+
const sha = await currentCommit(rootDir);
|
|
1373
|
+
if (!sha)
|
|
1374
|
+
return { ok: false, reason: "Could not resolve current commit (is this a git repo?)" };
|
|
1375
|
+
return { ok: true, sha };
|
|
1376
|
+
}
|
|
1377
|
+
async function resolveContext(rootDir, deploy, contextPrefix) {
|
|
1378
|
+
const user = await ghUser(rootDir);
|
|
1379
|
+
if (!user)
|
|
1380
|
+
return { ok: false, reason: "gh CLI is not authenticated; run `gh auth login`." };
|
|
1381
|
+
const action = deploy ? "deploy-attested" : "attested";
|
|
1382
|
+
return { ok: true, user, context: `${contextPrefix}/${action}/${user}` };
|
|
1383
|
+
}
|
|
1384
|
+
async function runAttest(opts) {
|
|
1385
|
+
const contextPrefix = opts.contextPrefix ?? "polly";
|
|
1386
|
+
const pre = await preflightOk(opts.rootDir);
|
|
1387
|
+
if (!pre.ok || !pre.sha) {
|
|
1388
|
+
return {
|
|
1389
|
+
ok: false,
|
|
1390
|
+
context: "",
|
|
1391
|
+
sha: "",
|
|
1392
|
+
digest: "",
|
|
1393
|
+
messages: [],
|
|
1394
|
+
error: pre.reason
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
const sha = pre.sha;
|
|
1398
|
+
const ctx = await resolveContext(opts.rootDir, opts.deploy === true, contextPrefix);
|
|
1399
|
+
if (!ctx.ok || !ctx.context) {
|
|
1400
|
+
return { ok: false, context: "", sha, digest: "", messages: [], error: ctx.reason };
|
|
1401
|
+
}
|
|
1402
|
+
const slug = await ghRepoSlug(opts.rootDir);
|
|
1403
|
+
if (!slug) {
|
|
1404
|
+
return {
|
|
1405
|
+
ok: false,
|
|
1406
|
+
context: ctx.context,
|
|
1407
|
+
sha,
|
|
1408
|
+
digest: "",
|
|
1409
|
+
messages: [],
|
|
1410
|
+
error: "Could not resolve GitHub repo slug; is `gh` configured for this repo?"
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
const report = await runChecks(opts.registry, opts.runConfig, undefined, {
|
|
1414
|
+
rootDir: opts.rootDir
|
|
1415
|
+
});
|
|
1416
|
+
if (!report.ok) {
|
|
1417
|
+
return {
|
|
1418
|
+
ok: false,
|
|
1419
|
+
context: ctx.context,
|
|
1420
|
+
sha,
|
|
1421
|
+
digest: "",
|
|
1422
|
+
messages: [
|
|
1423
|
+
`attest aborted: ${summariseRunReport(report)}`,
|
|
1424
|
+
...report.results.filter((r) => !r.ok).map((r) => ` ✗ ${r.id}`)
|
|
1425
|
+
],
|
|
1426
|
+
error: "one or more checks failed",
|
|
1427
|
+
report
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
const digest = digestRun(sha, report.results);
|
|
1431
|
+
const prior = await readPriorAttestation(opts.rootDir, sha);
|
|
1432
|
+
const description = `${summariseRunReport(report)} · ${digest.slice(0, 12)}`;
|
|
1433
|
+
const post = await postCommitStatus(opts.rootDir, slug, sha, ctx.context, description);
|
|
1434
|
+
if (!post.ok) {
|
|
1435
|
+
return {
|
|
1436
|
+
ok: false,
|
|
1437
|
+
context: ctx.context,
|
|
1438
|
+
sha,
|
|
1439
|
+
digest,
|
|
1440
|
+
messages: [post.output.trim()],
|
|
1441
|
+
error: "GitHub status post failed",
|
|
1442
|
+
report
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
await writeAttestation(opts.rootDir, sha, ctx.context, digest);
|
|
1446
|
+
const replayNote = prior === digest ? "replay (cached digest)" : "fresh attestation";
|
|
1447
|
+
return {
|
|
1448
|
+
ok: true,
|
|
1449
|
+
context: ctx.context,
|
|
1450
|
+
sha,
|
|
1451
|
+
digest,
|
|
1452
|
+
messages: [`${ctx.context} → ${sha.slice(0, 12)} (${replayNote})`, ` ${description}`],
|
|
1453
|
+
report
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
// tools/quality/src/config.ts
|
|
1457
|
+
import { join as join10 } from "node:path";
|
|
1458
|
+
|
|
1459
|
+
// tools/quality/src/plugins/core.ts
|
|
1460
|
+
import { join as join9 } from "node:path";
|
|
1461
|
+
import { Glob as Glob5 } from "bun";
|
|
1462
|
+
|
|
1463
|
+
// tools/quality/src/plugins/core-checks.ts
|
|
1464
|
+
import { readdir as readdir3 } from "node:fs/promises";
|
|
1465
|
+
import { join as join6, relative as relative3 } from "node:path";
|
|
1466
|
+
async function semgrepInstalled() {
|
|
1467
|
+
const which = Bun.spawn(["which", "semgrep"], { stdout: "ignore", stderr: "ignore" });
|
|
1468
|
+
await which.exited;
|
|
1469
|
+
return which.exitCode === 0;
|
|
1470
|
+
}
|
|
1471
|
+
var security = {
|
|
1472
|
+
id: "polly:security",
|
|
1473
|
+
description: "Run `semgrep scan` against the working tree (SAST)",
|
|
1474
|
+
cacheKeyExtras: (cfg) => {
|
|
1475
|
+
const c = cfg ?? {};
|
|
1476
|
+
return {
|
|
1477
|
+
config: c.config ?? "auto",
|
|
1478
|
+
severities: (c.severities ?? ["ERROR", "WARNING"]).slice().sort().join(","),
|
|
1479
|
+
exclude: (c.exclude ?? []).slice().sort().join(",")
|
|
1480
|
+
};
|
|
1481
|
+
},
|
|
1482
|
+
run: async ({ rootDir, config }) => {
|
|
1483
|
+
if (!await semgrepInstalled()) {
|
|
1484
|
+
return {
|
|
1485
|
+
ok: false,
|
|
1486
|
+
messages: [
|
|
1487
|
+
"semgrep is not on PATH. Install with `brew install semgrep` (macOS) or `pip install semgrep`."
|
|
1488
|
+
]
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
const cfg = config ?? {};
|
|
1492
|
+
const args = ["semgrep", "scan", "--config", cfg.config ?? "auto", "--quiet", "--error"];
|
|
1493
|
+
for (const sev of cfg.severities ?? ["ERROR", "WARNING"]) {
|
|
1494
|
+
args.push("--severity", sev);
|
|
1495
|
+
}
|
|
1496
|
+
for (const exc of cfg.exclude ?? ["Dockerfile*", "docker-compose*"]) {
|
|
1497
|
+
args.push(`--exclude=${exc}`);
|
|
1498
|
+
}
|
|
1499
|
+
const proc = Bun.spawn(args, { cwd: rootDir, stdout: "pipe", stderr: "pipe" });
|
|
1500
|
+
const [stdout, stderr] = await Promise.all([
|
|
1501
|
+
new Response(proc.stdout).text(),
|
|
1502
|
+
new Response(proc.stderr).text()
|
|
1503
|
+
]);
|
|
1504
|
+
await proc.exited;
|
|
1505
|
+
const output = `${stdout}${stderr}`.trim();
|
|
1506
|
+
return {
|
|
1507
|
+
ok: proc.exitCode === 0,
|
|
1508
|
+
messages: proc.exitCode === 0 ? [] : [output || `semgrep exited ${proc.exitCode}`]
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
var DEFAULT_BOUNDARIES = {
|
|
1513
|
+
bans: { src: ["tools/", "cli/", "scripts/"], tools: ["cli/", "scripts/"] },
|
|
1514
|
+
zones: ["src", "tools", "cli", "scripts"],
|
|
1515
|
+
skipDirs: ["node_modules", ".git", "dist", ".bun", ".cache"]
|
|
1516
|
+
};
|
|
1517
|
+
var IMPORT_REGEX = /(?:import|export)\s+.*?from\s+['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
1518
|
+
function isTestFile(rel) {
|
|
1519
|
+
return rel.includes("__tests__") || rel.includes(".test.") || rel.includes(".spec.") || rel.startsWith("tests/");
|
|
1520
|
+
}
|
|
1521
|
+
function resolveTargetZone(specifier, fromFile, rootDir) {
|
|
1522
|
+
if (!specifier.startsWith(".") && !specifier.startsWith("/"))
|
|
1523
|
+
return null;
|
|
1524
|
+
const fromDir = relative3(rootDir, fromFile).split("/").slice(0, -1).join("/");
|
|
1525
|
+
const segments = `${fromDir}/${specifier}`.split("/");
|
|
1526
|
+
const resolved = [];
|
|
1527
|
+
for (const seg of segments) {
|
|
1528
|
+
if (seg === "" || seg === ".")
|
|
1529
|
+
continue;
|
|
1530
|
+
if (seg === "..") {
|
|
1531
|
+
resolved.pop();
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
resolved.push(seg);
|
|
1535
|
+
}
|
|
1536
|
+
return resolved.join("/");
|
|
1537
|
+
}
|
|
1538
|
+
async function collectScannableFiles(dir, skipDirs, out) {
|
|
1539
|
+
let entries;
|
|
1540
|
+
try {
|
|
1541
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
1542
|
+
} catch {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
for (const entry of entries) {
|
|
1546
|
+
const fullPath = join6(dir, entry.name);
|
|
1547
|
+
if (entry.isDirectory()) {
|
|
1548
|
+
if (!skipDirs.has(entry.name))
|
|
1549
|
+
await collectScannableFiles(fullPath, skipDirs, out);
|
|
1550
|
+
} else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))) {
|
|
1551
|
+
out.push(fullPath);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
function violationsForBoundarySpecifier(specifier, ctx) {
|
|
1556
|
+
const target = resolveTargetZone(specifier, ctx.filePath, ctx.rootDir);
|
|
1557
|
+
if (!target)
|
|
1558
|
+
return [];
|
|
1559
|
+
const out = [];
|
|
1560
|
+
for (const banned of ctx.bans) {
|
|
1561
|
+
if (target.startsWith(banned)) {
|
|
1562
|
+
out.push({
|
|
1563
|
+
file: ctx.rel,
|
|
1564
|
+
line: ctx.line,
|
|
1565
|
+
rule: `"${ctx.zone}/" cannot import from "${banned}" (resolved: ${target})`
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return out;
|
|
1570
|
+
}
|
|
1571
|
+
function scanLineForBoundary(line, ctx) {
|
|
1572
|
+
const trimmed = line.trim();
|
|
1573
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*"))
|
|
1574
|
+
return [];
|
|
1575
|
+
const out = [];
|
|
1576
|
+
IMPORT_REGEX.lastIndex = 0;
|
|
1577
|
+
let match = IMPORT_REGEX.exec(line);
|
|
1578
|
+
while (match !== null) {
|
|
1579
|
+
const specifier = match[1] || match[2];
|
|
1580
|
+
if (specifier) {
|
|
1581
|
+
out.push(...violationsForBoundarySpecifier(specifier, { ...ctx, line: ctx.lineNumber }));
|
|
1582
|
+
}
|
|
1583
|
+
match = IMPORT_REGEX.exec(line);
|
|
1584
|
+
}
|
|
1585
|
+
return out;
|
|
1586
|
+
}
|
|
1587
|
+
function scanFileForBoundaryViolations(filePath, content, rootDir, zone, bans) {
|
|
1588
|
+
const rel = relative3(rootDir, filePath);
|
|
1589
|
+
const lines = content.split(`
|
|
1590
|
+
`);
|
|
1591
|
+
const out = [];
|
|
1592
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1593
|
+
out.push(...scanLineForBoundary(lines[i] ?? "", {
|
|
1594
|
+
filePath,
|
|
1595
|
+
rootDir,
|
|
1596
|
+
zone,
|
|
1597
|
+
bans,
|
|
1598
|
+
rel,
|
|
1599
|
+
lineNumber: i + 1
|
|
1600
|
+
}));
|
|
1601
|
+
}
|
|
1602
|
+
return out;
|
|
1603
|
+
}
|
|
1604
|
+
var boundaries = {
|
|
1605
|
+
id: "polly:boundaries",
|
|
1606
|
+
description: "Enforce directional import bans between top-level zones (src/tools/cli/scripts)",
|
|
1607
|
+
filesRead: async (cfg, root) => {
|
|
1608
|
+
const c = { ...DEFAULT_BOUNDARIES, ...cfg ?? {} };
|
|
1609
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_BOUNDARIES.skipDirs ?? []);
|
|
1610
|
+
const out = [];
|
|
1611
|
+
for (const zone of c.zones ?? DEFAULT_BOUNDARIES.zones ?? []) {
|
|
1612
|
+
await collectScannableFiles(join6(root, zone), skipDirs, out);
|
|
1613
|
+
}
|
|
1614
|
+
return out.filter((p) => !isTestFile(relative3(root, p)));
|
|
1615
|
+
},
|
|
1616
|
+
run: async ({ rootDir, config }) => {
|
|
1617
|
+
const c = { ...DEFAULT_BOUNDARIES, ...config ?? {} };
|
|
1618
|
+
const bans = c.bans ?? {};
|
|
1619
|
+
const zones = c.zones ?? [];
|
|
1620
|
+
const skipDirs = new Set(c.skipDirs ?? []);
|
|
1621
|
+
const all = [];
|
|
1622
|
+
for (const zone of zones) {
|
|
1623
|
+
const files = [];
|
|
1624
|
+
await collectScannableFiles(join6(rootDir, zone), skipDirs, files);
|
|
1625
|
+
const fromBans = bans[zone] ?? [];
|
|
1626
|
+
if (fromBans.length === 0)
|
|
1627
|
+
continue;
|
|
1628
|
+
for (const filePath of files) {
|
|
1629
|
+
if (isTestFile(relative3(rootDir, filePath)))
|
|
1630
|
+
continue;
|
|
1631
|
+
const content = await Bun.file(filePath).text();
|
|
1632
|
+
all.push(...scanFileForBoundaryViolations(filePath, content, rootDir, zone, fromBans));
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return {
|
|
1636
|
+
ok: all.length === 0,
|
|
1637
|
+
messages: all.map((v) => `${v.file}:${v.line}: ${v.rule}`)
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
var DEFAULT_SERVER_IMPORTS = {
|
|
1642
|
+
browserPaths: [
|
|
1643
|
+
"src/popup",
|
|
1644
|
+
"src/content",
|
|
1645
|
+
"src/devtools",
|
|
1646
|
+
"src/options",
|
|
1647
|
+
"src/offscreen",
|
|
1648
|
+
"src/page",
|
|
1649
|
+
"src/ui",
|
|
1650
|
+
"src/polly-ui",
|
|
1651
|
+
"tools/test/src/browser"
|
|
1652
|
+
],
|
|
1653
|
+
bannedSpecifiers: [
|
|
1654
|
+
"bun:sqlite",
|
|
1655
|
+
"bun:ffi",
|
|
1656
|
+
"bun:jsc",
|
|
1657
|
+
"node:fs",
|
|
1658
|
+
"node:fs/promises",
|
|
1659
|
+
"node:path",
|
|
1660
|
+
"node:child_process",
|
|
1661
|
+
"node:net",
|
|
1662
|
+
"node:os",
|
|
1663
|
+
"node:http",
|
|
1664
|
+
"node:https",
|
|
1665
|
+
"node:stream",
|
|
1666
|
+
"node:worker_threads",
|
|
1667
|
+
"node:cluster",
|
|
1668
|
+
"node:tls",
|
|
1669
|
+
"node:dns",
|
|
1670
|
+
"better-sqlite3",
|
|
1671
|
+
"sqlite3",
|
|
1672
|
+
"ws"
|
|
1673
|
+
],
|
|
1674
|
+
skipDirs: ["node_modules", "dist", ".cache"]
|
|
1675
|
+
};
|
|
1676
|
+
function buildBannedRegex(specs) {
|
|
1677
|
+
const escaped = specs.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
1678
|
+
return new RegExp(`(?:import|export)\\s+.*?from\\s+['"](?:${escaped})(?:/[^'"]*)?['"]|require\\(\\s*['"](?:${escaped})(?:/[^'"]*)?['"]\\s*\\)`);
|
|
1679
|
+
}
|
|
1680
|
+
function isExcludedFromServerCheck(rel, allow) {
|
|
1681
|
+
if (allow.has(rel))
|
|
1682
|
+
return true;
|
|
1683
|
+
return rel.includes("__tests__") || rel.includes(".test.") || rel.includes(".spec.");
|
|
1684
|
+
}
|
|
1685
|
+
function scanFileForServerImports(rel, content, bannedRegex) {
|
|
1686
|
+
const violations = [];
|
|
1687
|
+
const lines = content.split(`
|
|
1688
|
+
`);
|
|
1689
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1690
|
+
const line = lines[i] ?? "";
|
|
1691
|
+
const trimmed = line.trim();
|
|
1692
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*"))
|
|
1693
|
+
continue;
|
|
1694
|
+
if (bannedRegex.test(line)) {
|
|
1695
|
+
violations.push(`${rel}:${i + 1}: ${trimmed}`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return violations;
|
|
1699
|
+
}
|
|
1700
|
+
var serverImports = {
|
|
1701
|
+
id: "polly:server-imports",
|
|
1702
|
+
description: "Ban node:* / bun:* imports inside browser-targeting packages",
|
|
1703
|
+
filesRead: async (cfg, root) => {
|
|
1704
|
+
const c = cfg ?? {};
|
|
1705
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_SERVER_IMPORTS.skipDirs);
|
|
1706
|
+
const allow = new Set(c.allowFiles ?? []);
|
|
1707
|
+
const out = [];
|
|
1708
|
+
for (const p of c.browserPaths ?? DEFAULT_SERVER_IMPORTS.browserPaths) {
|
|
1709
|
+
await collectScannableFiles(join6(root, p), skipDirs, out);
|
|
1710
|
+
}
|
|
1711
|
+
return out.filter((p) => !isExcludedFromServerCheck(relative3(root, p), allow));
|
|
1712
|
+
},
|
|
1713
|
+
run: async ({ rootDir, config }) => {
|
|
1714
|
+
const c = config ?? {};
|
|
1715
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_SERVER_IMPORTS.skipDirs);
|
|
1716
|
+
const bannedRegex = buildBannedRegex(c.bannedSpecifiers ?? DEFAULT_SERVER_IMPORTS.bannedSpecifiers);
|
|
1717
|
+
const allow = new Set(c.allowFiles ?? []);
|
|
1718
|
+
const violations = [];
|
|
1719
|
+
for (const p of c.browserPaths ?? DEFAULT_SERVER_IMPORTS.browserPaths) {
|
|
1720
|
+
const files = [];
|
|
1721
|
+
await collectScannableFiles(join6(rootDir, p), skipDirs, files);
|
|
1722
|
+
for (const filePath of files) {
|
|
1723
|
+
const rel = relative3(rootDir, filePath);
|
|
1724
|
+
if (isExcludedFromServerCheck(rel, allow))
|
|
1725
|
+
continue;
|
|
1726
|
+
const content = await Bun.file(filePath).text();
|
|
1727
|
+
violations.push(...scanFileForServerImports(rel, content, bannedRegex));
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
return { ok: violations.length === 0, messages: violations };
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
var additionalCoreChecks = [
|
|
1734
|
+
security,
|
|
1735
|
+
boundaries,
|
|
1736
|
+
serverImports
|
|
1737
|
+
];
|
|
1738
|
+
|
|
1739
|
+
// tools/quality/src/plugins/extra-checks.ts
|
|
1740
|
+
import { readdir as readdir4 } from "node:fs/promises";
|
|
1741
|
+
import { join as join7, relative as relative4 } from "node:path";
|
|
1742
|
+
import { Glob as Glob3 } from "bun";
|
|
1743
|
+
var SKIP_DIRS_DEFAULT = ["node_modules", ".git", "dist", ".bun", ".cache"];
|
|
1744
|
+
function isCommentLine2(trimmed) {
|
|
1745
|
+
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
|
1746
|
+
}
|
|
1747
|
+
function isTestFile2(rel) {
|
|
1748
|
+
return rel.includes("__tests__") || rel.includes(".test.") || rel.includes(".spec.") || rel.startsWith("tests/");
|
|
1749
|
+
}
|
|
1750
|
+
async function walkScannableFiles(dir, skipDirs, out) {
|
|
1751
|
+
let entries;
|
|
1752
|
+
try {
|
|
1753
|
+
entries = await readdir4(dir, { withFileTypes: true });
|
|
1754
|
+
} catch {
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
for (const entry of entries) {
|
|
1758
|
+
const fullPath = join7(dir, entry.name);
|
|
1759
|
+
if (entry.isDirectory()) {
|
|
1760
|
+
if (!skipDirs.has(entry.name))
|
|
1761
|
+
await walkScannableFiles(fullPath, skipDirs, out);
|
|
1762
|
+
} else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))) {
|
|
1763
|
+
out.push(fullPath);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
var DEFAULT_FORBIDDEN = {
|
|
1768
|
+
banned: {
|
|
1769
|
+
"Alternative test runners (we use bun:test)": [
|
|
1770
|
+
"vitest",
|
|
1771
|
+
"jest",
|
|
1772
|
+
"@jest/globals",
|
|
1773
|
+
"mocha",
|
|
1774
|
+
"ava",
|
|
1775
|
+
"tape",
|
|
1776
|
+
"tap",
|
|
1777
|
+
"uvu"
|
|
1778
|
+
],
|
|
1779
|
+
"Alternative linters/formatters (we use biome)": [
|
|
1780
|
+
"eslint",
|
|
1781
|
+
"@eslint",
|
|
1782
|
+
"prettier",
|
|
1783
|
+
"@prettier",
|
|
1784
|
+
"tslint"
|
|
1785
|
+
],
|
|
1786
|
+
"Deprecated HTTP libraries (use fetch)": [
|
|
1787
|
+
"request",
|
|
1788
|
+
"request-promise",
|
|
1789
|
+
"request-promise-native"
|
|
1790
|
+
],
|
|
1791
|
+
"Date-handling libraries (use Date or built-ins)": ["moment", "moment-timezone"]
|
|
1792
|
+
},
|
|
1793
|
+
zones: ["src", "tools", "cli", "scripts"],
|
|
1794
|
+
skipDirs: SKIP_DIRS_DEFAULT
|
|
1795
|
+
};
|
|
1796
|
+
var IMPORT_REGEX2 = /(?:import|export)\s+.*?from\s+['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
1797
|
+
function lookupBanned(specifier, banned) {
|
|
1798
|
+
for (const [category, prefixes] of Object.entries(banned)) {
|
|
1799
|
+
for (const p of prefixes) {
|
|
1800
|
+
if (specifier === p || specifier.startsWith(`${p}/`))
|
|
1801
|
+
return { category };
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
1806
|
+
function scanLineForBanned(line, banned, rel, lineNumber) {
|
|
1807
|
+
const trimmed = line.trim();
|
|
1808
|
+
if (isCommentLine2(trimmed))
|
|
1809
|
+
return [];
|
|
1810
|
+
const out = [];
|
|
1811
|
+
IMPORT_REGEX2.lastIndex = 0;
|
|
1812
|
+
let match = IMPORT_REGEX2.exec(line);
|
|
1813
|
+
while (match !== null) {
|
|
1814
|
+
const specifier = match[1] || match[2];
|
|
1815
|
+
const hit = specifier ? lookupBanned(specifier, banned) : null;
|
|
1816
|
+
if (hit) {
|
|
1817
|
+
out.push({ file: rel, line: lineNumber, content: trimmed, category: hit.category });
|
|
1818
|
+
}
|
|
1819
|
+
match = IMPORT_REGEX2.exec(line);
|
|
1820
|
+
}
|
|
1821
|
+
return out;
|
|
1822
|
+
}
|
|
1823
|
+
async function scanFileForForbidden(filePath, rel, banned) {
|
|
1824
|
+
const content = await Bun.file(filePath).text();
|
|
1825
|
+
const lines = content.split(`
|
|
1826
|
+
`);
|
|
1827
|
+
const out = [];
|
|
1828
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1829
|
+
for (const v of scanLineForBanned(lines[i] ?? "", banned, rel, i + 1)) {
|
|
1830
|
+
out.push(`[${v.category}] ${v.file}:${v.line}: ${v.content}`);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return out;
|
|
1834
|
+
}
|
|
1835
|
+
var forbiddenDeps = {
|
|
1836
|
+
id: "polly:forbidden-deps",
|
|
1837
|
+
description: "Ban imports from a configured list of packages (alternative tools, deprecated libs)",
|
|
1838
|
+
filesRead: async (cfg, root) => {
|
|
1839
|
+
const c = cfg ?? {};
|
|
1840
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_FORBIDDEN.skipDirs);
|
|
1841
|
+
const out = [];
|
|
1842
|
+
for (const z of c.zones ?? DEFAULT_FORBIDDEN.zones) {
|
|
1843
|
+
await walkScannableFiles(join7(root, z), skipDirs, out);
|
|
1844
|
+
}
|
|
1845
|
+
return out.filter((p) => !isTestFile2(relative4(root, p)));
|
|
1846
|
+
},
|
|
1847
|
+
run: async ({ rootDir, config }) => {
|
|
1848
|
+
const c = config ?? {};
|
|
1849
|
+
const banned = c.banned ?? DEFAULT_FORBIDDEN.banned;
|
|
1850
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_FORBIDDEN.skipDirs);
|
|
1851
|
+
const violations = [];
|
|
1852
|
+
for (const z of c.zones ?? DEFAULT_FORBIDDEN.zones) {
|
|
1853
|
+
const files = [];
|
|
1854
|
+
await walkScannableFiles(join7(rootDir, z), skipDirs, files);
|
|
1855
|
+
for (const filePath of files) {
|
|
1856
|
+
const rel = relative4(rootDir, filePath);
|
|
1857
|
+
if (isTestFile2(rel))
|
|
1858
|
+
continue;
|
|
1859
|
+
violations.push(...await scanFileForForbidden(filePath, rel, banned));
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
return { ok: violations.length === 0, messages: violations };
|
|
1863
|
+
}
|
|
1864
|
+
};
|
|
1865
|
+
var DEFAULT_NO_STATE_HOOKS = {
|
|
1866
|
+
banned: ["useState", "useReducer", "useSignal"],
|
|
1867
|
+
allowedFiles: [],
|
|
1868
|
+
zones: ["src", "tools", "cli", "scripts"],
|
|
1869
|
+
skipDirs: SKIP_DIRS_DEFAULT
|
|
1870
|
+
};
|
|
1871
|
+
function escapeForAlternation(name) {
|
|
1872
|
+
return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1873
|
+
}
|
|
1874
|
+
function buildHookRegex(banned) {
|
|
1875
|
+
const alts = banned.map(escapeForAlternation).join("|");
|
|
1876
|
+
return new RegExp(`\\b(${alts})\\b`);
|
|
1877
|
+
}
|
|
1878
|
+
function isAllowedByGlob(rel, allowedFiles) {
|
|
1879
|
+
for (const pattern of allowedFiles) {
|
|
1880
|
+
if (new Glob3(pattern).match(rel))
|
|
1881
|
+
return true;
|
|
1882
|
+
}
|
|
1883
|
+
return false;
|
|
1884
|
+
}
|
|
1885
|
+
var noStateHooks = {
|
|
1886
|
+
id: "polly:no-state-hooks",
|
|
1887
|
+
description: "Ban useState/useReducer/useSignal — polly is signals-first",
|
|
1888
|
+
filesRead: async (cfg, root) => {
|
|
1889
|
+
const c = cfg ?? {};
|
|
1890
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_NO_STATE_HOOKS.skipDirs);
|
|
1891
|
+
const allowed = c.allowedFiles ?? DEFAULT_NO_STATE_HOOKS.allowedFiles;
|
|
1892
|
+
const out = [];
|
|
1893
|
+
for (const z of c.zones ?? DEFAULT_NO_STATE_HOOKS.zones) {
|
|
1894
|
+
await walkScannableFiles(join7(root, z), skipDirs, out);
|
|
1895
|
+
}
|
|
1896
|
+
return out.filter((p) => {
|
|
1897
|
+
const rel = relative4(root, p);
|
|
1898
|
+
return !isTestFile2(rel) && !isAllowedByGlob(rel, allowed);
|
|
1899
|
+
});
|
|
1900
|
+
},
|
|
1901
|
+
run: async ({ rootDir, config }) => {
|
|
1902
|
+
const c = config ?? {};
|
|
1903
|
+
const banned = c.banned ?? DEFAULT_NO_STATE_HOOKS.banned;
|
|
1904
|
+
if (banned.length === 0)
|
|
1905
|
+
return { ok: true, messages: [] };
|
|
1906
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_NO_STATE_HOOKS.skipDirs);
|
|
1907
|
+
const allowed = c.allowedFiles ?? DEFAULT_NO_STATE_HOOKS.allowedFiles;
|
|
1908
|
+
const regex = buildHookRegex(banned);
|
|
1909
|
+
const violations = [];
|
|
1910
|
+
for (const z of c.zones ?? DEFAULT_NO_STATE_HOOKS.zones) {
|
|
1911
|
+
const files = [];
|
|
1912
|
+
await walkScannableFiles(join7(rootDir, z), skipDirs, files);
|
|
1913
|
+
for (const filePath of files) {
|
|
1914
|
+
const rel = relative4(rootDir, filePath);
|
|
1915
|
+
if (isTestFile2(rel) || isAllowedByGlob(rel, allowed))
|
|
1916
|
+
continue;
|
|
1917
|
+
violations.push(...await scanFileForHookCalls(filePath, rel, regex));
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
return { ok: violations.length === 0, messages: violations };
|
|
1921
|
+
}
|
|
1922
|
+
};
|
|
1923
|
+
async function scanFileForHookCalls(filePath, rel, regex) {
|
|
1924
|
+
const content = await Bun.file(filePath).text();
|
|
1925
|
+
const lines = content.split(`
|
|
1926
|
+
`);
|
|
1927
|
+
const out = [];
|
|
1928
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1929
|
+
const line = lines[i] ?? "";
|
|
1930
|
+
if (isCommentLine2(line.trim()))
|
|
1931
|
+
continue;
|
|
1932
|
+
if (regex.test(line))
|
|
1933
|
+
out.push(`${rel}:${i + 1}: ${line.trim()}`);
|
|
1934
|
+
}
|
|
1935
|
+
return out;
|
|
1936
|
+
}
|
|
1937
|
+
var STRAIGHT_PATTERN = /["']/;
|
|
1938
|
+
var TYPOGRAPHIC_PATTERN = /[‘’“”]/;
|
|
1939
|
+
var DEFAULT_TYPOGRAPHIC = {
|
|
1940
|
+
typographicZone: [],
|
|
1941
|
+
straightZone: [],
|
|
1942
|
+
zones: ["src", "tools", "cli", "scripts"],
|
|
1943
|
+
skipDirs: SKIP_DIRS_DEFAULT
|
|
1944
|
+
};
|
|
1945
|
+
function fileMatchesAnyGlob(rel, globs) {
|
|
1946
|
+
for (const g of globs) {
|
|
1947
|
+
if (new Glob3(g).match(rel))
|
|
1948
|
+
return true;
|
|
1949
|
+
}
|
|
1950
|
+
return false;
|
|
1951
|
+
}
|
|
1952
|
+
var typographicQuotes = {
|
|
1953
|
+
id: "polly:typographic-quotes",
|
|
1954
|
+
description: "Enforce typographic quotes inside configured zones, straight quotes outside (opt-in)",
|
|
1955
|
+
filesRead: async (cfg, root) => {
|
|
1956
|
+
const c = cfg ?? {};
|
|
1957
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_TYPOGRAPHIC.skipDirs);
|
|
1958
|
+
const out = [];
|
|
1959
|
+
for (const z of c.zones ?? DEFAULT_TYPOGRAPHIC.zones) {
|
|
1960
|
+
await walkScannableFiles(join7(root, z), skipDirs, out);
|
|
1961
|
+
}
|
|
1962
|
+
return out;
|
|
1963
|
+
},
|
|
1964
|
+
run: async ({ rootDir, config }) => {
|
|
1965
|
+
const c = config ?? {};
|
|
1966
|
+
const tz = c.typographicZone ?? DEFAULT_TYPOGRAPHIC.typographicZone;
|
|
1967
|
+
const sz = c.straightZone ?? DEFAULT_TYPOGRAPHIC.straightZone;
|
|
1968
|
+
if (tz.length === 0 && sz.length === 0)
|
|
1969
|
+
return { ok: true, messages: [] };
|
|
1970
|
+
return runTypographicScan(rootDir, c, tz, sz);
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
async function scanFileForQuoteViolations(filePath, rel, inTypographicZone, inStraightZone) {
|
|
1974
|
+
const content = await Bun.file(filePath).text();
|
|
1975
|
+
const lines = content.split(`
|
|
1976
|
+
`);
|
|
1977
|
+
const out = [];
|
|
1978
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1979
|
+
const line = lines[i] ?? "";
|
|
1980
|
+
if (inTypographicZone && STRAIGHT_PATTERN.test(line)) {
|
|
1981
|
+
out.push(`${rel}:${i + 1}: straight quote in typographic zone — ${line.trim()}`);
|
|
1982
|
+
}
|
|
1983
|
+
if (inStraightZone && TYPOGRAPHIC_PATTERN.test(line)) {
|
|
1984
|
+
out.push(`${rel}:${i + 1}: typographic quote in straight zone — ${line.trim()}`);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return out;
|
|
1988
|
+
}
|
|
1989
|
+
async function runTypographicScan(rootDir, c, tz, sz) {
|
|
1990
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_TYPOGRAPHIC.skipDirs);
|
|
1991
|
+
const violations = [];
|
|
1992
|
+
for (const z of c.zones ?? DEFAULT_TYPOGRAPHIC.zones) {
|
|
1993
|
+
const files = [];
|
|
1994
|
+
await walkScannableFiles(join7(rootDir, z), skipDirs, files);
|
|
1995
|
+
for (const filePath of files) {
|
|
1996
|
+
const rel = relative4(rootDir, filePath);
|
|
1997
|
+
const inT = fileMatchesAnyGlob(rel, tz);
|
|
1998
|
+
const inS = fileMatchesAnyGlob(rel, sz);
|
|
1999
|
+
if (!inT && !inS)
|
|
2000
|
+
continue;
|
|
2001
|
+
violations.push(...await scanFileForQuoteViolations(filePath, rel, inT, inS));
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
return { ok: violations.length === 0, messages: violations };
|
|
2005
|
+
}
|
|
2006
|
+
var extraCoreChecks = [
|
|
2007
|
+
forbiddenDeps,
|
|
2008
|
+
noStateHooks,
|
|
2009
|
+
typographicQuotes
|
|
2010
|
+
];
|
|
2011
|
+
|
|
2012
|
+
// tools/quality/src/plugins/import-checks.ts
|
|
2013
|
+
import { readdir as readdir5 } from "node:fs/promises";
|
|
2014
|
+
import { join as join8, relative as relative5 } from "node:path";
|
|
2015
|
+
import { Glob as Glob4 } from "bun";
|
|
2016
|
+
var SKIP_DIRS_DEFAULT2 = ["node_modules", ".git", "dist", ".bun", ".cache"];
|
|
2017
|
+
function isCommentLine3(trimmed) {
|
|
2018
|
+
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
|
2019
|
+
}
|
|
2020
|
+
function isTestFile3(rel) {
|
|
2021
|
+
return rel.includes("__tests__") || rel.includes(".test.") || rel.includes(".spec.") || rel.startsWith("tests/");
|
|
2022
|
+
}
|
|
2023
|
+
async function walkScannableFiles2(dir, skipDirs, out) {
|
|
2024
|
+
let entries;
|
|
2025
|
+
try {
|
|
2026
|
+
entries = await readdir5(dir, { withFileTypes: true });
|
|
2027
|
+
} catch {
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
for (const entry of entries) {
|
|
2031
|
+
const fullPath = join8(dir, entry.name);
|
|
2032
|
+
if (entry.isDirectory()) {
|
|
2033
|
+
if (!skipDirs.has(entry.name))
|
|
2034
|
+
await walkScannableFiles2(fullPath, skipDirs, out);
|
|
2035
|
+
} else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))) {
|
|
2036
|
+
out.push(fullPath);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
function isAllowedByGlob2(rel, globs) {
|
|
2041
|
+
for (const g of globs) {
|
|
2042
|
+
if (new Glob4(g).match(rel))
|
|
2043
|
+
return true;
|
|
2044
|
+
}
|
|
2045
|
+
return false;
|
|
2046
|
+
}
|
|
2047
|
+
var IMPORT_REGEX3 = /(?:import|export)\s+.*?from\s+['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
2048
|
+
function specifiersInLine(line) {
|
|
2049
|
+
const out = [];
|
|
2050
|
+
IMPORT_REGEX3.lastIndex = 0;
|
|
2051
|
+
let match = IMPORT_REGEX3.exec(line);
|
|
2052
|
+
while (match !== null) {
|
|
2053
|
+
const s = match[1] || match[2];
|
|
2054
|
+
if (s)
|
|
2055
|
+
out.push(s);
|
|
2056
|
+
match = IMPORT_REGEX3.exec(line);
|
|
2057
|
+
}
|
|
2058
|
+
return out;
|
|
2059
|
+
}
|
|
2060
|
+
var DEFAULT_RELATIVE = {
|
|
2061
|
+
maxDepth: 1,
|
|
2062
|
+
allowedFiles: [],
|
|
2063
|
+
zones: ["src", "tools", "cli", "scripts"],
|
|
2064
|
+
skipDirs: SKIP_DIRS_DEFAULT2
|
|
2065
|
+
};
|
|
2066
|
+
function relativeDepth(specifier) {
|
|
2067
|
+
if (!specifier.startsWith(".."))
|
|
2068
|
+
return 0;
|
|
2069
|
+
let depth = 0;
|
|
2070
|
+
for (const segment of specifier.split("/")) {
|
|
2071
|
+
if (segment === "..")
|
|
2072
|
+
depth++;
|
|
2073
|
+
else
|
|
2074
|
+
break;
|
|
2075
|
+
}
|
|
2076
|
+
return depth;
|
|
2077
|
+
}
|
|
2078
|
+
async function scanFileForRelativeViolations(filePath, rel, maxDepth) {
|
|
2079
|
+
const content = await Bun.file(filePath).text();
|
|
2080
|
+
const lines = content.split(`
|
|
2081
|
+
`);
|
|
2082
|
+
const out = [];
|
|
2083
|
+
for (let i = 0;i < lines.length; i++) {
|
|
2084
|
+
const line = lines[i] ?? "";
|
|
2085
|
+
if (isCommentLine3(line.trim()))
|
|
2086
|
+
continue;
|
|
2087
|
+
for (const specifier of specifiersInLine(line)) {
|
|
2088
|
+
if (relativeDepth(specifier) > maxDepth) {
|
|
2089
|
+
out.push(`${rel}:${i + 1}: import "${specifier}" exceeds maxDepth ${maxDepth}`);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
return out;
|
|
2094
|
+
}
|
|
2095
|
+
var relativeImports = {
|
|
2096
|
+
id: "polly:relative-imports",
|
|
2097
|
+
description: "Ban `../` imports deeper than a configured threshold",
|
|
2098
|
+
filesRead: async (cfg, root) => {
|
|
2099
|
+
const c = cfg ?? {};
|
|
2100
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_RELATIVE.skipDirs);
|
|
2101
|
+
const allowed = c.allowedFiles ?? DEFAULT_RELATIVE.allowedFiles;
|
|
2102
|
+
const out = [];
|
|
2103
|
+
for (const z of c.zones ?? DEFAULT_RELATIVE.zones) {
|
|
2104
|
+
await walkScannableFiles2(join8(root, z), skipDirs, out);
|
|
2105
|
+
}
|
|
2106
|
+
return out.filter((p) => {
|
|
2107
|
+
const rel = relative5(root, p);
|
|
2108
|
+
return !isTestFile3(rel) && !isAllowedByGlob2(rel, allowed);
|
|
2109
|
+
});
|
|
2110
|
+
},
|
|
2111
|
+
run: async ({ rootDir, config }) => {
|
|
2112
|
+
const c = config ?? {};
|
|
2113
|
+
const maxDepth = c.maxDepth ?? DEFAULT_RELATIVE.maxDepth;
|
|
2114
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_RELATIVE.skipDirs);
|
|
2115
|
+
const allowed = c.allowedFiles ?? DEFAULT_RELATIVE.allowedFiles;
|
|
2116
|
+
const violations = [];
|
|
2117
|
+
for (const z of c.zones ?? DEFAULT_RELATIVE.zones) {
|
|
2118
|
+
const files = [];
|
|
2119
|
+
await walkScannableFiles2(join8(rootDir, z), skipDirs, files);
|
|
2120
|
+
for (const filePath of files) {
|
|
2121
|
+
const rel = relative5(rootDir, filePath);
|
|
2122
|
+
if (isTestFile3(rel) || isAllowedByGlob2(rel, allowed))
|
|
2123
|
+
continue;
|
|
2124
|
+
violations.push(...await scanFileForRelativeViolations(filePath, rel, maxDepth));
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
return { ok: violations.length === 0, messages: violations };
|
|
2128
|
+
}
|
|
2129
|
+
};
|
|
2130
|
+
var DEFAULT_TSCONFIG_PATHS = {
|
|
2131
|
+
files: ["tsconfig.json", "tsconfig.base.json"],
|
|
2132
|
+
allowedAliases: []
|
|
2133
|
+
};
|
|
2134
|
+
async function findTsconfigs(rootDir, names) {
|
|
2135
|
+
const out = [];
|
|
2136
|
+
const skipDirs = new Set(SKIP_DIRS_DEFAULT2);
|
|
2137
|
+
async function walk(dir) {
|
|
2138
|
+
let entries;
|
|
2139
|
+
try {
|
|
2140
|
+
entries = await readdir5(dir, { withFileTypes: true });
|
|
2141
|
+
} catch {
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
for (const entry of entries) {
|
|
2145
|
+
const fullPath = join8(dir, entry.name);
|
|
2146
|
+
if (entry.isDirectory()) {
|
|
2147
|
+
if (!skipDirs.has(entry.name))
|
|
2148
|
+
await walk(fullPath);
|
|
2149
|
+
} else if (entry.isFile() && names.includes(entry.name)) {
|
|
2150
|
+
out.push(fullPath);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
await walk(rootDir);
|
|
2155
|
+
return out;
|
|
2156
|
+
}
|
|
2157
|
+
var tsconfigPaths = {
|
|
2158
|
+
id: "polly:tsconfig-paths",
|
|
2159
|
+
description: "Flag `compilerOptions.paths` entries (use package.json subpath imports instead)",
|
|
2160
|
+
filesRead: async (cfg, root) => findTsconfigs(root, cfg?.files ?? DEFAULT_TSCONFIG_PATHS.files),
|
|
2161
|
+
run: async ({ rootDir, config }) => {
|
|
2162
|
+
const c = config ?? {};
|
|
2163
|
+
const files = c.files ?? DEFAULT_TSCONFIG_PATHS.files;
|
|
2164
|
+
const allowed = new Set(c.allowedAliases ?? DEFAULT_TSCONFIG_PATHS.allowedAliases);
|
|
2165
|
+
const violations = [];
|
|
2166
|
+
for (const tsconfigPath of await findTsconfigs(rootDir, files)) {
|
|
2167
|
+
const raw = await Bun.file(tsconfigPath).text();
|
|
2168
|
+
const stripped = raw.replace(/\/\/[^\n]*\n/g, `
|
|
2169
|
+
`).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
2170
|
+
let parsed;
|
|
2171
|
+
try {
|
|
2172
|
+
parsed = JSON.parse(stripped);
|
|
2173
|
+
} catch {
|
|
2174
|
+
violations.push(`${relative5(rootDir, tsconfigPath)}: failed to parse as JSON`);
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2177
|
+
const paths = parsed.compilerOptions?.paths ?? {};
|
|
2178
|
+
for (const alias of Object.keys(paths)) {
|
|
2179
|
+
if (allowed.has(alias))
|
|
2180
|
+
continue;
|
|
2181
|
+
violations.push(`${relative5(rootDir, tsconfigPath)}: paths["${alias}"] is set`);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
return { ok: violations.length === 0, messages: violations };
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2187
|
+
var DEFAULT_NO_RAW_HTTP = {
|
|
2188
|
+
banned: ["fetch", "XMLHttpRequest", "axios"],
|
|
2189
|
+
allowedFiles: [],
|
|
2190
|
+
zones: ["src"],
|
|
2191
|
+
skipDirs: SKIP_DIRS_DEFAULT2
|
|
2192
|
+
};
|
|
2193
|
+
function buildBannedCallRegex(banned) {
|
|
2194
|
+
const alts = banned.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
2195
|
+
return new RegExp(`\\b(${alts})\\s*[\\(.]`);
|
|
2196
|
+
}
|
|
2197
|
+
async function scanFileForRawHttp(filePath, rel, canonicalClient, bannedRegex) {
|
|
2198
|
+
const content = await Bun.file(filePath).text();
|
|
2199
|
+
const lines = content.split(`
|
|
2200
|
+
`);
|
|
2201
|
+
const out = [];
|
|
2202
|
+
const isCanonicalImpl = lines.some((l) => l.includes(`from "${canonicalClient}"`) || l.includes(`from '${canonicalClient}'`));
|
|
2203
|
+
for (let i = 0;i < lines.length; i++) {
|
|
2204
|
+
const line = lines[i] ?? "";
|
|
2205
|
+
const trimmed = line.trim();
|
|
2206
|
+
if (isCommentLine3(trimmed))
|
|
2207
|
+
continue;
|
|
2208
|
+
if (bannedRegex.test(line) && !isCanonicalImpl) {
|
|
2209
|
+
out.push(`${rel}:${i + 1}: ${trimmed}`);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
return out;
|
|
2213
|
+
}
|
|
2214
|
+
var noRawHttp = {
|
|
2215
|
+
id: "polly:no-raw-http",
|
|
2216
|
+
description: "Force HTTP calls through a configured canonical client (opt-in)",
|
|
2217
|
+
filesRead: async (cfg, root) => {
|
|
2218
|
+
if (!cfg?.canonicalClient)
|
|
2219
|
+
return [];
|
|
2220
|
+
const skipDirs = new Set(cfg.skipDirs ?? DEFAULT_NO_RAW_HTTP.skipDirs);
|
|
2221
|
+
const allowed = cfg.allowedFiles ?? DEFAULT_NO_RAW_HTTP.allowedFiles;
|
|
2222
|
+
const out = [];
|
|
2223
|
+
for (const z of cfg.zones ?? DEFAULT_NO_RAW_HTTP.zones) {
|
|
2224
|
+
await walkScannableFiles2(join8(root, z), skipDirs, out);
|
|
2225
|
+
}
|
|
2226
|
+
return out.filter((p) => {
|
|
2227
|
+
const rel = relative5(root, p);
|
|
2228
|
+
return !isTestFile3(rel) && !isAllowedByGlob2(rel, allowed);
|
|
2229
|
+
});
|
|
2230
|
+
},
|
|
2231
|
+
run: async ({ rootDir, config }) => {
|
|
2232
|
+
const c = config ?? {};
|
|
2233
|
+
if (!c.canonicalClient) {
|
|
2234
|
+
return { ok: true, messages: [] };
|
|
2235
|
+
}
|
|
2236
|
+
const banned = c.banned ?? DEFAULT_NO_RAW_HTTP.banned;
|
|
2237
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_NO_RAW_HTTP.skipDirs);
|
|
2238
|
+
const allowed = c.allowedFiles ?? DEFAULT_NO_RAW_HTTP.allowedFiles;
|
|
2239
|
+
const bannedRegex = buildBannedCallRegex(banned);
|
|
2240
|
+
const violations = [];
|
|
2241
|
+
for (const z of c.zones ?? DEFAULT_NO_RAW_HTTP.zones) {
|
|
2242
|
+
const files = [];
|
|
2243
|
+
await walkScannableFiles2(join8(rootDir, z), skipDirs, files);
|
|
2244
|
+
for (const filePath of files) {
|
|
2245
|
+
const rel = relative5(rootDir, filePath);
|
|
2246
|
+
if (isTestFile3(rel) || isAllowedByGlob2(rel, allowed))
|
|
2247
|
+
continue;
|
|
2248
|
+
violations.push(...await scanFileForRawHttp(filePath, rel, c.canonicalClient, bannedRegex));
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
return { ok: violations.length === 0, messages: violations };
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
async function runTscFor(packagePath, rootDir) {
|
|
2255
|
+
const proc = Bun.spawn(["bunx", "tsc", "--noEmit", "-p", packagePath], {
|
|
2256
|
+
cwd: rootDir,
|
|
2257
|
+
stdout: "pipe",
|
|
2258
|
+
stderr: "pipe"
|
|
2259
|
+
});
|
|
2260
|
+
const [stdout, stderr] = await Promise.all([
|
|
2261
|
+
new Response(proc.stdout).text(),
|
|
2262
|
+
new Response(proc.stderr).text()
|
|
2263
|
+
]);
|
|
2264
|
+
await proc.exited;
|
|
2265
|
+
return { ok: proc.exitCode === 0, output: `${stdout}${stderr}` };
|
|
2266
|
+
}
|
|
2267
|
+
async function findWorkspaces(rootDir, patterns) {
|
|
2268
|
+
const out = [];
|
|
2269
|
+
for (const pattern of patterns) {
|
|
2270
|
+
const glob = new Glob4(`${pattern}/tsconfig.json`);
|
|
2271
|
+
for await (const file of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
2272
|
+
out.push(file.replace(/\/tsconfig\.json$/, ""));
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
return [...new Set(out)];
|
|
2276
|
+
}
|
|
2277
|
+
var types = {
|
|
2278
|
+
id: "polly:types",
|
|
2279
|
+
description: "Run `tsc --noEmit` against each workspace package in parallel",
|
|
2280
|
+
filesRead: async () => {
|
|
2281
|
+
return [];
|
|
2282
|
+
},
|
|
2283
|
+
run: async ({ rootDir, config }) => {
|
|
2284
|
+
const patterns = (config ?? {}).workspaces ?? ["packages/*", "."];
|
|
2285
|
+
const packages = await findWorkspaces(rootDir, patterns);
|
|
2286
|
+
if (packages.length === 0)
|
|
2287
|
+
return { ok: true, messages: ["no workspace packages found"] };
|
|
2288
|
+
const results = await Promise.all(packages.map((p) => runTscFor(p, rootDir)));
|
|
2289
|
+
return aggregateTypesResults(packages, results, rootDir);
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
function appendTscFailure(messages, relPkg, output) {
|
|
2293
|
+
messages.push(`${relPkg}: tsc --noEmit failed`);
|
|
2294
|
+
const trimmed = output.trim();
|
|
2295
|
+
if (!trimmed)
|
|
2296
|
+
return;
|
|
2297
|
+
for (const ln of trimmed.split(`
|
|
2298
|
+
`).slice(0, 30))
|
|
2299
|
+
messages.push(` ${ln}`);
|
|
2300
|
+
}
|
|
2301
|
+
function aggregateTypesResults(packages, results, rootDir) {
|
|
2302
|
+
const messages = [];
|
|
2303
|
+
let ok = true;
|
|
2304
|
+
for (let i = 0;i < packages.length; i++) {
|
|
2305
|
+
const pkg = packages[i];
|
|
2306
|
+
const r = results[i];
|
|
2307
|
+
if (!pkg || !r || r.ok)
|
|
2308
|
+
continue;
|
|
2309
|
+
ok = false;
|
|
2310
|
+
appendTscFailure(messages, relative5(rootDir, pkg) || ".", r.output);
|
|
2311
|
+
}
|
|
2312
|
+
return { ok, messages };
|
|
2313
|
+
}
|
|
2314
|
+
var importCoreChecks = [
|
|
2315
|
+
relativeImports,
|
|
2316
|
+
tsconfigPaths,
|
|
2317
|
+
noRawHttp,
|
|
2318
|
+
types
|
|
2319
|
+
];
|
|
2320
|
+
|
|
2321
|
+
// tools/quality/src/plugins/core.ts
|
|
2322
|
+
var DEFAULT_EXCLUDES = ["node_modules", "dist", ".git", ".bun", "dist-test", "build", "coverage"];
|
|
2323
|
+
async function scanFiles(rootDir, cfg) {
|
|
2324
|
+
const excludeDirs = new Set(cfg.exclude ?? DEFAULT_EXCLUDES);
|
|
2325
|
+
const excludePackages = new Set(cfg.excludePackages ?? []);
|
|
2326
|
+
const excludeFiles = new Set(cfg.excludeFiles ?? []);
|
|
2327
|
+
const pattern = cfg.filePatterns ?? "**/*.{ts,tsx}";
|
|
2328
|
+
const glob = new Glob5(pattern);
|
|
2329
|
+
const out = [];
|
|
2330
|
+
for await (const file of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
2331
|
+
const rel = file.replace(`${rootDir}/`, "");
|
|
2332
|
+
const segments = rel.split("/");
|
|
2333
|
+
if (segments.some((s) => excludeDirs.has(s)))
|
|
2334
|
+
continue;
|
|
2335
|
+
if (excludePackages.size > 0 && segments.some((s) => excludePackages.has(s)))
|
|
2336
|
+
continue;
|
|
2337
|
+
const basename = segments[segments.length - 1] ?? "";
|
|
2338
|
+
if (excludeFiles.has(basename) || excludeFiles.has(rel))
|
|
2339
|
+
continue;
|
|
2340
|
+
out.push(file);
|
|
2341
|
+
}
|
|
2342
|
+
return out;
|
|
2343
|
+
}
|
|
2344
|
+
function resolveScanConfig(config) {
|
|
2345
|
+
return config ?? {};
|
|
2346
|
+
}
|
|
2347
|
+
var noAsCasting = {
|
|
2348
|
+
id: "polly:no-as-casting",
|
|
2349
|
+
description: "Ban TypeScript `as` type assertions outside the allowed forms",
|
|
2350
|
+
filesRead: (cfg, root) => scanFiles(root, resolveScanConfig(cfg)),
|
|
2351
|
+
run: async ({ rootDir, config }) => {
|
|
2352
|
+
const cfg = resolveScanConfig(config);
|
|
2353
|
+
const result = await checkNoAsCasting({
|
|
2354
|
+
rootDir,
|
|
2355
|
+
exclude: cfg.exclude ?? DEFAULT_EXCLUDES,
|
|
2356
|
+
...cfg.excludePackages ? { excludePackages: cfg.excludePackages } : {},
|
|
2357
|
+
...cfg.excludeFiles ? { excludeFiles: cfg.excludeFiles } : {},
|
|
2358
|
+
...cfg.filePatterns ? { filePatterns: cfg.filePatterns } : {}
|
|
2359
|
+
});
|
|
2360
|
+
return {
|
|
2361
|
+
ok: result.violations.length === 0,
|
|
2362
|
+
messages: result.violations.map((v) => `${v.file}:${v.line}: ${v.content}${v.advice ? ` — ${v.advice}` : ""}`)
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
};
|
|
2366
|
+
var noRequire = {
|
|
2367
|
+
id: "polly:no-require",
|
|
2368
|
+
description: "Ban CommonJS `require(...)` calls in TypeScript source",
|
|
2369
|
+
filesRead: (cfg, root) => scanFiles(root, resolveScanConfig(cfg)),
|
|
2370
|
+
run: async ({ rootDir, config }) => {
|
|
2371
|
+
const cfg = resolveScanConfig(config);
|
|
2372
|
+
const result = await checkNoRequire({
|
|
2373
|
+
rootDir,
|
|
2374
|
+
exclude: cfg.exclude ?? DEFAULT_EXCLUDES,
|
|
2375
|
+
...cfg.filePatterns ? { filePatterns: cfg.filePatterns } : {}
|
|
2376
|
+
});
|
|
2377
|
+
return {
|
|
2378
|
+
ok: result.violations.length === 0,
|
|
2379
|
+
messages: result.violations.map((v) => `${v.file}:${v.line}: ${v.content}`)
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
var secrets = {
|
|
2384
|
+
id: "polly:secrets",
|
|
2385
|
+
description: "Run `gitleaks detect` against the working tree",
|
|
2386
|
+
filesRead: async (cfg, root) => {
|
|
2387
|
+
const c = cfg ?? {};
|
|
2388
|
+
if (!c.configPath)
|
|
2389
|
+
return [];
|
|
2390
|
+
return [join9(root, c.configPath)];
|
|
2391
|
+
},
|
|
2392
|
+
cacheKeyExtras: (cfg) => ({
|
|
2393
|
+
noGit: (cfg ?? {}).noGit === false ? "false" : "true"
|
|
2394
|
+
}),
|
|
2395
|
+
run: async ({ rootDir, config }) => {
|
|
2396
|
+
const cfg = config ?? {};
|
|
2397
|
+
const result = await checkSecrets({
|
|
2398
|
+
root: rootDir,
|
|
2399
|
+
...cfg.configPath ? { configPath: cfg.configPath } : {},
|
|
2400
|
+
...typeof cfg.noGit === "boolean" ? { noGit: cfg.noGit } : {}
|
|
2401
|
+
});
|
|
2402
|
+
if (!result.binaryFound) {
|
|
2403
|
+
return {
|
|
2404
|
+
ok: false,
|
|
2405
|
+
messages: [
|
|
2406
|
+
"gitleaks is not on PATH. Install with `brew install gitleaks` (macOS) or from https://github.com/gitleaks/gitleaks/releases."
|
|
2407
|
+
]
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
return {
|
|
2411
|
+
ok: result.exitCode === 0,
|
|
2412
|
+
messages: result.exitCode === 0 ? [] : [result.output.trim() || `gitleaks exited ${result.exitCode}`]
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
};
|
|
2416
|
+
var gitignoreCrossCheck = {
|
|
2417
|
+
id: "polly:gitignore-cross-check",
|
|
2418
|
+
description: "Verify every gitleaks allowlist entry marked as gitignored is actually covered by .gitignore",
|
|
2419
|
+
filesRead: (cfg, root) => {
|
|
2420
|
+
const c = cfg ?? {};
|
|
2421
|
+
return [
|
|
2422
|
+
join9(root, c.tomlPath ?? ".gitleaks.toml"),
|
|
2423
|
+
join9(root, c.gitignorePath ?? ".gitignore")
|
|
2424
|
+
];
|
|
2425
|
+
},
|
|
2426
|
+
run: async ({ rootDir, config }) => {
|
|
2427
|
+
const cfg = config ?? {};
|
|
2428
|
+
const result = await checkGitignoreCoversAllowlist({
|
|
2429
|
+
root: rootDir,
|
|
2430
|
+
...cfg.tomlPath ? { tomlPath: cfg.tomlPath } : {},
|
|
2431
|
+
...cfg.gitignorePath ? { gitignorePath: cfg.gitignorePath } : {},
|
|
2432
|
+
...cfg.sectionStartMarkers ? { sectionStartMarkers: cfg.sectionStartMarkers } : {},
|
|
2433
|
+
...cfg.sectionEndMarkers ? { sectionEndMarkers: cfg.sectionEndMarkers } : {}
|
|
2434
|
+
});
|
|
2435
|
+
return {
|
|
2436
|
+
ok: result.missing.length === 0,
|
|
2437
|
+
messages: result.missing.length === 0 ? [] : [
|
|
2438
|
+
"Paths allowed by .gitleaks.toml are NOT covered by .gitignore:",
|
|
2439
|
+
...result.missing.map((f) => ` ${f}`)
|
|
2440
|
+
]
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
};
|
|
2444
|
+
var POLLY_CORE_VERSION = "0.45.0";
|
|
2445
|
+
var pollyCorePlugin = {
|
|
2446
|
+
name: "polly",
|
|
2447
|
+
version: POLLY_CORE_VERSION,
|
|
2448
|
+
checks: [
|
|
2449
|
+
noAsCasting,
|
|
2450
|
+
noRequire,
|
|
2451
|
+
secrets,
|
|
2452
|
+
gitignoreCrossCheck,
|
|
2453
|
+
...additionalCoreChecks,
|
|
2454
|
+
...extraCoreChecks,
|
|
2455
|
+
...importCoreChecks
|
|
2456
|
+
]
|
|
2457
|
+
};
|
|
2458
|
+
|
|
2459
|
+
// tools/quality/src/config.ts
|
|
2460
|
+
var DEFAULT_PATHS = ["polly.config.ts", "polly.config.js", "polly.config.mjs"];
|
|
2461
|
+
async function loadQualityConfig(rootDir, explicitPath) {
|
|
2462
|
+
const candidates = explicitPath ? [explicitPath] : DEFAULT_PATHS.map((p) => join10(rootDir, p));
|
|
2463
|
+
for (const path of candidates) {
|
|
2464
|
+
if (!await Bun.file(path).exists())
|
|
2465
|
+
continue;
|
|
2466
|
+
const mod = await import(path);
|
|
2467
|
+
const config = mod.default?.quality;
|
|
2468
|
+
if (!config)
|
|
2469
|
+
continue;
|
|
2470
|
+
if (!Array.isArray(config.plugins) || config.plugins.length === 0) {
|
|
2471
|
+
throw new Error(`${path}: \`quality.plugins\` must be a non-empty array`);
|
|
2472
|
+
}
|
|
2473
|
+
return config;
|
|
2474
|
+
}
|
|
2475
|
+
return { plugins: [pollyCorePlugin] };
|
|
2476
|
+
}
|
|
2477
|
+
// tools/quality/src/plugins/polly-ui.ts
|
|
2478
|
+
import { readdir as readdir6 } from "node:fs/promises";
|
|
2479
|
+
import { join as join11, relative as relative6 } from "node:path";
|
|
2480
|
+
import { Glob as Glob6 } from "bun";
|
|
2481
|
+
var SKIP_DIRS_DEFAULT3 = ["node_modules", ".git", "dist", ".bun", ".cache"];
|
|
2482
|
+
function isCommentLine4(trimmed) {
|
|
2483
|
+
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
|
2484
|
+
}
|
|
2485
|
+
function isAllowedByGlob3(rel, globs) {
|
|
2486
|
+
for (const g of globs) {
|
|
2487
|
+
if (new Glob6(g).match(rel))
|
|
2488
|
+
return true;
|
|
2489
|
+
}
|
|
2490
|
+
return false;
|
|
2491
|
+
}
|
|
2492
|
+
async function walkScannableFiles3(dir, skipDirs, exts, out) {
|
|
2493
|
+
let entries;
|
|
2494
|
+
try {
|
|
2495
|
+
entries = await readdir6(dir, { withFileTypes: true });
|
|
2496
|
+
} catch {
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
for (const entry of entries) {
|
|
2500
|
+
const fullPath = join11(dir, entry.name);
|
|
2501
|
+
if (entry.isDirectory()) {
|
|
2502
|
+
if (!skipDirs.has(entry.name))
|
|
2503
|
+
await walkScannableFiles3(fullPath, skipDirs, exts, out);
|
|
2504
|
+
} else if (entry.isFile() && exts.some((e) => entry.name.endsWith(e))) {
|
|
2505
|
+
out.push(fullPath);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
var cssLayout = {
|
|
2510
|
+
id: "polly-ui:css-layout",
|
|
2511
|
+
description: "Enforce that layout values come from the <Layout> primitive, not ad-hoc CSS",
|
|
2512
|
+
run: async ({ rootDir, config }) => {
|
|
2513
|
+
const r = await checkCssLayout({
|
|
2514
|
+
rootDir,
|
|
2515
|
+
...config?.skipDirs ? { skipDirs: config.skipDirs } : {}
|
|
2516
|
+
});
|
|
2517
|
+
return {
|
|
2518
|
+
ok: r.violations.length === 0,
|
|
2519
|
+
messages: r.violations.map((v) => `${v.file}:${v.line}: ${v.rule}`)
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
};
|
|
2523
|
+
var cssQuality = {
|
|
2524
|
+
id: "polly-ui:css-quality",
|
|
2525
|
+
description: "Enforce that styled values come from semantic tokens, not literals",
|
|
2526
|
+
run: async ({ rootDir, config }) => {
|
|
2527
|
+
const r = await checkCssQuality({
|
|
2528
|
+
rootDir,
|
|
2529
|
+
...config?.skipDirs ? { skipDirs: config.skipDirs } : {}
|
|
2530
|
+
});
|
|
2531
|
+
return {
|
|
2532
|
+
ok: r.violations.length === 0,
|
|
2533
|
+
messages: r.violations.map((v) => `${v.file}:${v.line}: ${v.rule}`)
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
};
|
|
2537
|
+
var cssVars = {
|
|
2538
|
+
id: "polly-ui:css-vars",
|
|
2539
|
+
description: "Flag `var(--name)` references that resolve to no `--name:` definition",
|
|
2540
|
+
run: async ({ rootDir, config }) => {
|
|
2541
|
+
const r = await checkCssVars({
|
|
2542
|
+
rootDir,
|
|
2543
|
+
...config?.skipDirs ? { skipDirs: config.skipDirs } : {}
|
|
2544
|
+
});
|
|
2545
|
+
return {
|
|
2546
|
+
ok: r.violations.length === 0,
|
|
2547
|
+
messages: r.violations.map((v) => `${v.file}:${v.line}: ${v.rule}`)
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
};
|
|
2551
|
+
var cssUnused = {
|
|
2552
|
+
id: "polly-ui:css-unused",
|
|
2553
|
+
description: "Surface CSS-module selectors that are never referenced from JS/TSX (advisory)",
|
|
2554
|
+
run: async ({ rootDir, config }) => {
|
|
2555
|
+
const r = await checkCssUnused({
|
|
2556
|
+
rootDir,
|
|
2557
|
+
...config?.skipDirs ? { skipDirs: config.skipDirs } : {}
|
|
2558
|
+
});
|
|
2559
|
+
return {
|
|
2560
|
+
ok: true,
|
|
2561
|
+
messages: r.violations.map((v) => `${v.file}:${v.line}: ${v.rule}`)
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2564
|
+
};
|
|
2565
|
+
var sharedComponents = {
|
|
2566
|
+
id: "polly-ui:shared-components",
|
|
2567
|
+
description: "Ban native interactive HTML elements; require the polly-ui primitive (<Button>, <Modal>, etc.)",
|
|
2568
|
+
run: async ({ rootDir, config }) => {
|
|
2569
|
+
const c = config ?? {};
|
|
2570
|
+
const r = await checkSharedComponents({
|
|
2571
|
+
root: rootDir,
|
|
2572
|
+
...c.scanRoot ? { scanRoot: c.scanRoot } : {},
|
|
2573
|
+
...c.skipDirs ? { skipDirs: new Set(c.skipDirs) } : {},
|
|
2574
|
+
...c.exemptPackages ? { exemptPackages: new Set(c.exemptPackages) } : {}
|
|
2575
|
+
});
|
|
2576
|
+
return {
|
|
2577
|
+
ok: r.violations.length === 0,
|
|
2578
|
+
messages: r.violations.map((v) => `${v.file}:${v.line}: ${v.element} → ${v.replacement} — ${v.content}`)
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
};
|
|
2582
|
+
var DEFAULT_NO_INLINE_HANDLERS = {
|
|
2583
|
+
banned: [
|
|
2584
|
+
"onClick",
|
|
2585
|
+
"onSubmit",
|
|
2586
|
+
"onChange",
|
|
2587
|
+
"onInput",
|
|
2588
|
+
"onFocus",
|
|
2589
|
+
"onBlur",
|
|
2590
|
+
"onKeyDown",
|
|
2591
|
+
"onKeyUp",
|
|
2592
|
+
"onMouseDown",
|
|
2593
|
+
"onMouseUp",
|
|
2594
|
+
"onMouseEnter",
|
|
2595
|
+
"onMouseLeave"
|
|
2596
|
+
],
|
|
2597
|
+
allowedFiles: [],
|
|
2598
|
+
zones: ["src"],
|
|
2599
|
+
skipDirs: SKIP_DIRS_DEFAULT3
|
|
2600
|
+
};
|
|
2601
|
+
function escapeRegex(name) {
|
|
2602
|
+
return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2603
|
+
}
|
|
2604
|
+
function buildHandlerRegex(banned) {
|
|
2605
|
+
const alts = banned.map(escapeRegex).join("|");
|
|
2606
|
+
return new RegExp(`\\b(${alts})\\s*=\\s*\\{`);
|
|
2607
|
+
}
|
|
2608
|
+
function isTestFile4(rel) {
|
|
2609
|
+
return rel.includes("__tests__") || rel.includes(".test.") || rel.includes(".spec.") || rel.startsWith("tests/");
|
|
2610
|
+
}
|
|
2611
|
+
async function scanFileForInlineHandlers(filePath, rel, regex) {
|
|
2612
|
+
const content = await Bun.file(filePath).text();
|
|
2613
|
+
const lines = content.split(`
|
|
2614
|
+
`);
|
|
2615
|
+
const out = [];
|
|
2616
|
+
for (let i = 0;i < lines.length; i++) {
|
|
2617
|
+
const line = lines[i] ?? "";
|
|
2618
|
+
if (isCommentLine4(line.trim()))
|
|
2619
|
+
continue;
|
|
2620
|
+
if (regex.test(line)) {
|
|
2621
|
+
out.push(`${rel}:${i + 1}: ${line.trim()}`);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
return out;
|
|
2625
|
+
}
|
|
2626
|
+
var noInlineHandlers = {
|
|
2627
|
+
id: "polly-ui:no-inline-handlers",
|
|
2628
|
+
description: "Ban inline JSX event handlers (use data-action delegation instead — see polly-ui actions runtime)",
|
|
2629
|
+
filesRead: async (cfg, root) => {
|
|
2630
|
+
const c = cfg ?? {};
|
|
2631
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_NO_INLINE_HANDLERS.skipDirs);
|
|
2632
|
+
const allowed = c.allowedFiles ?? DEFAULT_NO_INLINE_HANDLERS.allowedFiles;
|
|
2633
|
+
const out = [];
|
|
2634
|
+
for (const z of c.zones ?? DEFAULT_NO_INLINE_HANDLERS.zones) {
|
|
2635
|
+
await walkScannableFiles3(join11(root, z), skipDirs, [".tsx"], out);
|
|
2636
|
+
}
|
|
2637
|
+
return out.filter((p) => {
|
|
2638
|
+
const rel = relative6(root, p);
|
|
2639
|
+
return !isTestFile4(rel) && !isAllowedByGlob3(rel, allowed);
|
|
2640
|
+
});
|
|
2641
|
+
},
|
|
2642
|
+
run: async ({ rootDir, config }) => {
|
|
2643
|
+
const c = config ?? {};
|
|
2644
|
+
const banned = c.banned ?? DEFAULT_NO_INLINE_HANDLERS.banned;
|
|
2645
|
+
if (banned.length === 0)
|
|
2646
|
+
return { ok: true, messages: [] };
|
|
2647
|
+
const skipDirs = new Set(c.skipDirs ?? DEFAULT_NO_INLINE_HANDLERS.skipDirs);
|
|
2648
|
+
const allowed = c.allowedFiles ?? DEFAULT_NO_INLINE_HANDLERS.allowedFiles;
|
|
2649
|
+
const regex = buildHandlerRegex(banned);
|
|
2650
|
+
const violations = [];
|
|
2651
|
+
for (const z of c.zones ?? DEFAULT_NO_INLINE_HANDLERS.zones) {
|
|
2652
|
+
const files = [];
|
|
2653
|
+
await walkScannableFiles3(join11(rootDir, z), skipDirs, [".tsx"], files);
|
|
2654
|
+
for (const filePath of files) {
|
|
2655
|
+
const rel = relative6(rootDir, filePath);
|
|
2656
|
+
if (isTestFile4(rel) || isAllowedByGlob3(rel, allowed))
|
|
2657
|
+
continue;
|
|
2658
|
+
violations.push(...await scanFileForInlineHandlers(filePath, rel, regex));
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
return { ok: violations.length === 0, messages: violations };
|
|
2662
|
+
}
|
|
2663
|
+
};
|
|
2664
|
+
var POLLY_UI_PLUGIN_VERSION = "0.46.0";
|
|
2665
|
+
var pollyUiPlugin = {
|
|
2666
|
+
name: "polly-ui",
|
|
2667
|
+
version: POLLY_UI_PLUGIN_VERSION,
|
|
2668
|
+
checks: [
|
|
2669
|
+
cssLayout,
|
|
2670
|
+
cssQuality,
|
|
2671
|
+
cssVars,
|
|
2672
|
+
cssUnused,
|
|
2673
|
+
sharedComponents,
|
|
2674
|
+
noInlineHandlers
|
|
2675
|
+
]
|
|
2676
|
+
};
|
|
1056
2677
|
export {
|
|
2678
|
+
validateRunConfig,
|
|
2679
|
+
summariseRunReport,
|
|
1057
2680
|
suggestFix,
|
|
2681
|
+
setCachedOutcome,
|
|
2682
|
+
runChecks,
|
|
2683
|
+
runAttest,
|
|
1058
2684
|
resetLogger,
|
|
2685
|
+
registerPlugins,
|
|
2686
|
+
pollyUiPlugin,
|
|
2687
|
+
pollyCorePlugin,
|
|
1059
2688
|
logger,
|
|
2689
|
+
loadQualityConfig,
|
|
2690
|
+
listChecks,
|
|
1060
2691
|
isLineRequireClean,
|
|
1061
2692
|
isLineClean,
|
|
2693
|
+
getCachedOutcome,
|
|
2694
|
+
digestRun,
|
|
2695
|
+
computeInputsHash,
|
|
1062
2696
|
checkSharedComponents,
|
|
1063
2697
|
checkSecrets,
|
|
1064
2698
|
checkNoRequire,
|
|
@@ -1068,7 +2702,9 @@ export {
|
|
|
1068
2702
|
checkCssUnused,
|
|
1069
2703
|
checkCssQuality,
|
|
1070
2704
|
checkCssLayout,
|
|
2705
|
+
POLLY_UI_PLUGIN_VERSION,
|
|
2706
|
+
POLLY_CORE_VERSION,
|
|
1071
2707
|
DEFAULT_SHARED_COMPONENT_RULES
|
|
1072
2708
|
};
|
|
1073
2709
|
|
|
1074
|
-
//# debugId=
|
|
2710
|
+
//# debugId=D75CC99DF86A22C764756E2164756E21
|