Skip to content

Commit d0c5cd0

Browse files
committed
ci: enforce the solhint ratchet and align lint workflow naming
Add the lint-ratchet job and per-rule base check; rename the lint jobs to format-and-lint. Signed-off-by: Miguel_LZPF <miguel.carpena@io.builders>
1 parent 81b6a01 commit d0c5cd0

4 files changed

Lines changed: 156 additions & 8 deletions

File tree

.github/workflows/103-flow-ats-lint.yaml

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: "103: [FLOW] ATS Lint and Format"
1+
name: "103: [FLOW] ATS Format and Lint"
22

33
on:
44
pull_request:
@@ -11,6 +11,10 @@ on:
1111
- ".github/workflows/800-call-lint-format.yaml"
1212
workflow_dispatch:
1313

14+
defaults:
15+
run:
16+
shell: bash
17+
1418
permissions:
1519
contents: read
1620

@@ -19,8 +23,54 @@ concurrency:
1923
cancel-in-progress: true
2024

2125
jobs:
22-
lint-and-format:
23-
name: lint-and-format
26+
format-and-lint:
27+
name: format-and-lint
2428
uses: ./.github/workflows/800-call-lint-format.yaml
2529
with:
2630
module: ats
31+
32+
# Solhint warning ratchet for packages/ats/contracts: the warning count may only
33+
# stay equal or decrease, never increase. Two read-only checks (no push-back):
34+
# 1. `betterer ci` — the committed .betterer.results matches the actual code.
35+
# 2. check-ratchet-vs-base.ts — per-rule counts are not worse than the base
36+
# branch's (blocks an upward re-baseline that check 1 alone would accept).
37+
# The baseline is maintained locally by the contracts `lint`/`lint:fix` scripts.
38+
lint-ratchet:
39+
name: lint-ratchet
40+
runs-on: token-studio-linux-medium
41+
timeout-minutes: 15
42+
permissions:
43+
contents: read
44+
steps:
45+
- name: Harden Runner
46+
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
47+
with:
48+
egress-policy: audit
49+
50+
- name: Checkout repository
51+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
52+
with:
53+
# Need the base branch ref reachable so the base comparison can read
54+
# its committed .betterer.results via `git show`.
55+
fetch-depth: 0
56+
57+
- name: Setup NodeJS Environment
58+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
59+
with:
60+
node-version-file: .nvmrc
61+
cache: "npm"
62+
63+
- name: Install dependencies
64+
run: npm ci
65+
66+
# betterer runs solhint, whose accessor generator imports @contract-types
67+
# (TypeChain). npm ci does not build it (ignore-scripts skips prepare), so
68+
# compile first to emit typechain-types/ before the ratchet check.
69+
- name: Build TypeChain types
70+
run: npm run compile --workspace=packages/ats/contracts
71+
72+
- name: Check baseline is honest (betterer ci)
73+
run: npm run lint:ratchet --workspace=packages/ats/contracts
74+
75+
- name: Check baseline is not worse than base branch
76+
run: npx tsx packages/ats/contracts/scripts/ci/check-ratchet-vs-base.ts "${{ github.base_ref }}"

.github/workflows/104-flow-mp-lint.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: "104: [FLOW] MP Lint and Format"
1+
name: "104: [FLOW] MP Format and Lint"
22

33
on:
44
pull_request:
@@ -19,8 +19,8 @@ concurrency:
1919
cancel-in-progress: true
2020

2121
jobs:
22-
lint-and-format:
23-
name: lint-and-format
22+
format-and-lint:
23+
name: format-and-lint
2424
uses: ./.github/workflows/800-call-lint-format.yaml
2525
with:
2626
module: mass-payout

.github/workflows/800-call-lint-format.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ permissions:
2626
contents: read
2727

2828
jobs:
29-
lint-and-format:
30-
name: lint-and-format
29+
format-and-lint:
30+
name: format-and-lint
3131
runs-on: ${{ inputs.runs-on }}
3232
timeout-minutes: ${{ inputs.timeout-minutes }}
3333
steps:
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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

Comments
 (0)