|
| 1 | +// SPDX-License-Identifier: Apache-2.0 |
| 2 | +// Ratchet gate: the PR's solhint baseline must not be worse than the base branch's, |
| 3 | +// per rule. `betterer ci` (run separately) already proves the committed |
| 4 | +// `.betterer.results` matches the actual code; this guards against a PR that |
| 5 | +// regresses the code AND re-baselines `.betterer.results` upward to match — which |
| 6 | +// `betterer ci` alone would accept. Comparing per-rule counts (rather than the |
| 7 | +// per-issue hashes, which change on any code edit) keeps the gate stable across |
| 8 | +// legitimate refactors while still failing any rule whose warning count grows. |
| 9 | +// |
| 10 | +// Usage: npx tsx packages/ats/contracts/scripts/ci/check-ratchet-vs-base.ts <base-ref> (run from repo root) |
| 11 | + |
| 12 | +import { execFileSync } from "node:child_process"; |
| 13 | +import { readFileSync } from "node:fs"; |
| 14 | + |
| 15 | +const RESULTS_PATH = "packages/ats/contracts/.betterer.results"; |
| 16 | +const baseRef = process.argv[2] || "develop"; |
| 17 | + |
| 18 | +// A serialised betterer file issue: [line, column, length, message, hash]. |
| 19 | +type SerialisedIssue = [number, number, number, string, string]; |
| 20 | +type BettererResultsModule = Record<string, { value: string }>; |
| 21 | + |
| 22 | +/** |
| 23 | + * Tally solhint warnings per rule from a `.betterer.results` source. |
| 24 | + * |
| 25 | + * The file is a CommonJS module that stores each test's serialised result as a |
| 26 | + * template literal (`exports[name] = { value: `...` }`), so backslash escapes |
| 27 | + * (e.g. `it\'s`) only resolve once JS evaluates it. We evaluate the module the |
| 28 | + * same way betterer does, then JSON.parse each test's value. |
| 29 | + */ |
| 30 | +function parseResults(source: string): Map<string, number> { |
| 31 | + const counts = new Map<string, number>(); // ruleId -> count |
| 32 | + const exportsObject: BettererResultsModule = Object.create(null); |
| 33 | + // eslint-disable-next-line no-new-func |
| 34 | + new Function("exports", source)(exportsObject); |
| 35 | + for (const entry of Object.values(exportsObject)) { |
| 36 | + const byFile = JSON.parse(entry.value) as Record<string, SerialisedIssue[]>; |
| 37 | + for (const issues of Object.values(byFile)) { |
| 38 | + for (const issue of issues) { |
| 39 | + const message = issue[3] ?? ""; |
| 40 | + // Issues are recorded as `"<ruleId>: <message>"` by `.betterer.ts`; the |
| 41 | + // ruleId is the text before the first colon (solhint ruleIds have none). |
| 42 | + const ruleId = message.slice(0, message.indexOf(":")) || "unknown"; |
| 43 | + counts.set(ruleId, (counts.get(ruleId) ?? 0) + 1); |
| 44 | + } |
| 45 | + } |
| 46 | + } |
| 47 | + return counts; |
| 48 | +} |
| 49 | + |
| 50 | +function readBaseResults(): string | null { |
| 51 | + for (const ref of [`origin/${baseRef}`, baseRef]) { |
| 52 | + try { |
| 53 | + return execFileSync("git", ["show", `${ref}:${RESULTS_PATH}`], { |
| 54 | + encoding: "utf8", |
| 55 | + maxBuffer: 64 * 1024 * 1024, |
| 56 | + stdio: ["ignore", "pipe", "ignore"], |
| 57 | + }); |
| 58 | + } catch { |
| 59 | + // try the next ref form |
| 60 | + } |
| 61 | + } |
| 62 | + return null; |
| 63 | +} |
| 64 | + |
| 65 | +const baseSource = readBaseResults(); |
| 66 | +if (baseSource === null) { |
| 67 | + console.log(`✅ No \`${RESULTS_PATH}\` on \`${baseRef}\` yet — establishing the baseline. Skipping comparison.`); |
| 68 | + process.exit(0); |
| 69 | +} |
| 70 | + |
| 71 | +const headSource = readFileSync(RESULTS_PATH, "utf8"); |
| 72 | +const baseCounts = parseResults(baseSource); |
| 73 | +const headCounts = parseResults(headSource); |
| 74 | + |
| 75 | +const regressions: Array<{ ruleId: string; baseCount: number; headCount: number }> = []; |
| 76 | +for (const [ruleId, headCount] of headCounts) { |
| 77 | + const baseCount = baseCounts.get(ruleId) ?? 0; |
| 78 | + if (headCount > baseCount) { |
| 79 | + regressions.push({ ruleId, baseCount, headCount }); |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +const baseTotal = [...baseCounts.values()].reduce((a, b) => a + b, 0); |
| 84 | +const headTotal = [...headCounts.values()].reduce((a, b) => a + b, 0); |
| 85 | + |
| 86 | +if (regressions.length > 0) { |
| 87 | + console.error(`❌ Solhint warnings increased vs \`${baseRef}\` (${baseTotal} → ${headTotal}). Regressed rules:`); |
| 88 | + for (const { ruleId, baseCount, headCount } of regressions) { |
| 89 | + console.error(` • ${ruleId}: ${baseCount} → ${headCount}`); |
| 90 | + } |
| 91 | + console.error( |
| 92 | + "\nFix the new warnings, then run `npm run ats:contracts:lint:fix` and commit the updated\n" + |
| 93 | + "`packages/ats/contracts/.betterer.results`. The contracts lint baseline may only stay equal or shrink.", |
| 94 | + ); |
| 95 | + process.exit(1); |
| 96 | +} |
| 97 | + |
| 98 | +console.log(`✅ Solhint warnings not worse than \`${baseRef}\` (${baseTotal} → ${headTotal}). No rule increased.`); |
0 commit comments