| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import fs from "node:fs"; |
| 4 | import path from "node:path"; |
| 5 | |
| 6 | const ROOT = path.resolve(new URL("..", import.meta.url).pathname); |
| 7 | |
| 8 | const BANNED = [ |
| 9 | "slashed", "gutted", "slammed", "blasted", "massive", "tiny", "huge", |
| 10 | "sweeping", "radical", "extreme", "controversial", "popular", "unpopular", |
| 11 | "landmark", "historic", "devastating", "crippling", "generous", "stingy", |
| 12 | "bloated", "wasteful", "common-sense", "partisan", "crucial", "vital", |
| 13 | "narrowly", "barely", "overwhelmingly", "finally", "surprisingly", "key", |
| 14 | ]; |
| 15 | |
| 16 | const ALLOW_HOSTS = [ |
| 17 | "congress.gov", "api.congress.gov", "govinfo.gov", "usaspending.gov", |
| 18 | "fiscaldata.treasury.gov", "cbo.gov", "gpo.gov", "clerk.house.gov", |
| 19 | "senate.gov", "openstates.org", "house.gov", "whitehouse.gov", |
| 20 | ]; |
| 21 | |
| 22 | const ALLOW_HOST_RE = /(^|\.)gov$|\.gov$/i; |
| 23 | |
| 24 | const SCANS = [ |
| 25 | "lib/mock-data.ts", |
| 26 | "app/register/page.tsx", |
| 27 | "app/bill/[id]/page.tsx", |
| 28 | "app/spending/page.tsx", |
| 29 | "app/states/page.tsx", |
| 30 | "app/how-we-pick/page.tsx", |
| 31 | "app/who-pays/page.tsx", |
| 32 | "components/site-footer.tsx", |
| 33 | ]; |
| 34 | |
| 35 | let violations = 0; |
| 36 | |
| 37 | function checkAllowed(url, file) { |
| 38 | let host; |
| 39 | try { |
| 40 | host = new URL(url).host.toLowerCase().replace(/^www\./, ""); |
| 41 | } catch { |
| 42 | violations++; |
| 43 | console.log(` โ ${file}: malformed sourceUrl "${url}"`); |
| 44 | return; |
| 45 | } |
| 46 | const ok = ALLOW_HOST_RE.test(host) || ALLOW_HOSTS.includes(host); |
| 47 | if (!ok) { |
| 48 | violations++; |
| 49 | console.log(` โ ${file}: source host "${host}" not allowlisted`); |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | console.log("Docket neutrality lint"); |
| 54 | console.log("----------------------"); |
| 55 | |
| 56 | const bannedRe = new RegExp(`\\b(${BANNED.join("|")})\\b`, "gi"); |
| 57 | console.log("\n#2 no adjectives"); |
| 58 | for (const rel of SCANS) { |
| 59 | const file = path.join(ROOT, rel); |
| 60 | if (!fs.existsSync(file)) { |
| 61 | console.log(` - ${rel}: skipped (not found)`); |
| 62 | continue; |
| 63 | } |
| 64 | const text = fs.readFileSync(file, "utf8"); |
| 65 | let m; |
| 66 | while ((m = bannedRe.exec(text))) { |
| 67 | if (/BANNED|banned/i.test(text.slice(Math.max(0, m.index - 60), m.index))) { |
| 68 | continue; |
| 69 | } |
| 70 | violations++; |
| 71 | const line = text.slice(0, m.index).split("\n").length; |
| 72 | console.log(` โ ${rel}:${line} banned word "${m[1]}"`); |
| 73 | } |
| 74 | console.log(` - ${rel}: scanned`); |
| 75 | } |
| 76 | |
| 77 | console.log("\n#1 government sources"); |
| 78 | const dataFile = path.join(ROOT, "lib/mock-data.ts"); |
| 79 | if (fs.existsSync(dataFile)) { |
| 80 | const text = fs.readFileSync(dataFile, "utf8"); |
| 81 | const urlRe = /sourceUrl:\s*"([^"]+)"/g; |
| 82 | let m; |
| 83 | let count = 0; |
| 84 | while ((m = urlRe.exec(text))) { |
| 85 | checkAllowed(m[1], "lib/mock-data.ts"); |
| 86 | count++; |
| 87 | } |
| 88 | console.log(` - checked ${count} sourceUrl values`); |
| 89 | } |
| 90 | |
| 91 | console.log("\n----------------------"); |
| 92 | if (violations === 0) { |
| 93 | console.log("PASS - 0 neutrality violations"); |
| 94 | process.exit(0); |
| 95 | } |
| 96 | console.log(`FAIL - ${violations} neutrality violation(s)`); |
| 97 | process.exit(1); |