Split kondo ratchet budgets into separate prod and test files (#83383)

* Split kondo ratchet budgets into separate prod and test files

Prod and test kondo suppressions shared one .clj-kondo/ratchets.edn budget per
linter, so a pile of test-code ignores could mask a prod-code regression (or
vice versa) inside the same number. Adds .clj-kondo/ratchets-test.edn, tracking
:ignore-counts for anything under a `test` directory, independent of the prod
file's budget. :config-counts (.clj-kondo/config.edn suppressions) stays
combined in the prod file -- config.edn's own scoping mixes test and prod
namespaces in several groups, so it can't be reliably split per suppression.

Updates every consumer: dev.kondo-ratchet's fix!/check, bin/merge-kondo-ratchets,
the backport and release-cut disable scripts, the shrink workflow, and
file-paths.yaml/.gitignore.

* Fix test-ratchets validation and exemption-suggestion bugs from roborev

fix!/check read the test ratchets file with *ratchets-file* rebound to
*test-ratchets-file*, but validated it after that binding ended, so an
unknown linter there was reported against the prod path instead. Validation
now runs inside the binding, and both now reject a nonempty :config-counts
on the test file instead of letting fix! silently drop it.

kondo-insert-ignores also only ever suggested adding a new linter's
:comment-exempt to the prod file, even when every unjustified occurrence
was test-only. It now checks prod and test occurrences against each file's
own exemptions independently and names whichever file(s) actually need it.

* Dedupe test-ratchets read+validate, simplify test-occurrence?

fix! and check each rebuilt the same read-validate-test-file sequence
inline; extracted into read-validated-test-ratchets. test-occurrence?
no longer builds a throwaway set just to check one membership.

* Note what splitting :config-counts later would actually cost

* Fix Eastwood reflection warning in new test helper

empty-test-ratchets-file! returned an untyped value, so .getPath on it
needed reflection at each call site. Hinting the return type as
java.io.File fixes it -- CI's changed-tests Eastwood run caught this.

* Move return type hint onto the arg vector

Clj-Kondo prefers the hint on the arg vector to the one on the fn name.

* Move shrink PR body file reading into dev.kondo-ratchet

The bb task only parses its arguments now; reading and combining the
ratchet files lives next to shrink-pr-body, with a test.

* Give the test ratchets file its own header and drop its :config-counts

The test file was rendered with the prod header, whose last lines point
readers at ratchets-test.edn and say :config-counts "stays here" -- both
wrong inside the test file. It also carried an empty :config-counts that
the check rejects whenever it's nonempty.

render-test writes the test file with its own header and no
:config-counts, and throws on a nonempty one rather than dropping it.
fix!, the normalization check, and the three-way merge (a new "test"
kind) all use it.
This commit is contained in:
Chris Truter
2026-10-01 15:26:37 -04:00
committed by GitHub
parent bdd6335f59
commit e4471f76d3
18 changed files with 764 additions and 197 deletions

View File

@@ -0,0 +1,35 @@
;; Budgets for kondo suppressions in test code: inline `:clj-kondo/ignore` forms per linter
;; (:ignore-counts). Production code has its own budgets in .clj-kondo/ratchets.edn, which also holds
;; every config-level waiver (:config-counts) -- config.edn's scoping doesn't cleanly split.
;; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling.
;; Checks fail when a count exceeds its numeric budget; unused budget is allowed.
;; Any ignore outside :comment-exempt needs an explanatory comment directly above or trailing on its line.
;; `./bin/mage kondo-ratchets-shrink` lowers budgets unless `--seed` explicitly adds or raises one.
;; The workflow runs it on master, so feature branches do not need to record reductions.
;; Raising or adding a budget (`--seed` for inline ignores, a manual edit otherwise) or widening the
;; exemptions must be explained in the PR.
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
{:ignore-counts {:case-symbol-test 1
:clojure-lsp/unused-public-var 1
:deprecated-namespace 66
:deprecated-var 32
:discouraged-namespace 8
:discouraged-var 59
:equals-true 1
:metabase/disallow-hardcoded-driver-names-in-tests 34
:metabase/discourage-dynamic-vars 41
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13
:metabase/namespace-name 1
:metabase/prefer-with-dynamic-fn-redefs :unlimited
:metabase/test-helpers-use-non-thread-safe-functions 18
:metabase/validate-deftest 9
:missing-protocol-method 3
:type-mismatch 6
:uninitialized-var 1
:unresolved-symbol 2
:unused-binding 6
:unused-private-var 2}
:comment-exempt #{:deprecated-namespace
:discouraged-namespace
:discouraged-var
:metabase/discourage-dynamic-vars}}

View File

@@ -8,49 +8,43 @@
;; Raising or adding a budget (`--seed` for inline ignores, a manual edit otherwise) or widening the
;; exemptions must be explained in the PR.
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
{:ignore-counts {:aliased-namespace-symbol 3
:case-symbol-test 1
:clojure-lsp/unused-public-var 9
:consistent-alias 2
:def-fn 1
:deprecated-namespace 95
:deprecated-var 85
:discouraged-java-method 4
:discouraged-namespace 68
:discouraged-var 132
:duplicate-require 1
:equals-true 1
:main-without-gen-class 1
:metabase/disallow-hardcoded-driver-names-in-tests 34
:metabase/discourage-dynamic-vars 364
:metabase/discourage-millis-duration 1
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13
:metabase/modules 23
:metabase/namespace-name 4
:metabase/prefer-with-dynamic-fn-redefs :unlimited
:metabase/test-helpers-use-non-thread-safe-functions 18
:metabase/validate-defendpoint-has-response-schema 427
:metabase/validate-defendpoint-query-params-use-kebab-case 48
:metabase/validate-defendpoint-route-uses-kebab-case 44
:metabase/validate-deftest 9
:missing-docstring 4
:missing-protocol-method 3
:non-arg-vec-return-type-hint 1
:reduce-without-init 3
:redundant-do 1
:syntax 2
:type-mismatch 8
:uninitialized-var 1
:unquote-not-syntax-quoted 1
:unresolved-namespace 3
:unresolved-symbol 11
:unsorted-required-namespaces :unlimited
:unused-alias 2
:unused-binding 9
:unused-excluded-var 2
:unused-namespace 2
:unused-private-var 8
:unused-value 1}
;;
;; Test code's :ignore-counts has its own budget in .clj-kondo/ratchets-test.edn.
;; :config-counts stays here either way -- config.edn's scoping doesn't cleanly split.
{:ignore-counts {:aliased-namespace-symbol 3
:clojure-lsp/unused-public-var 8
:consistent-alias 2
:def-fn 1
:deprecated-namespace 29
:deprecated-var 53
:discouraged-java-method 4
:discouraged-namespace 60
:discouraged-var 73
:duplicate-require 1
:main-without-gen-class 1
:metabase/discourage-dynamic-vars 325
:metabase/discourage-millis-duration 1
:metabase/modules 23
:metabase/namespace-name 3
:metabase/validate-defendpoint-has-response-schema 427
:metabase/validate-defendpoint-query-params-use-kebab-case 48
:metabase/validate-defendpoint-route-uses-kebab-case 44
:missing-docstring 4
:non-arg-vec-return-type-hint 1
:reduce-without-init 3
:redundant-do 1
:syntax 2
:type-mismatch 2
:unquote-not-syntax-quoted 1
:unresolved-namespace 3
:unresolved-symbol 9
:unsorted-required-namespaces :unlimited
:unused-alias 2
:unused-binding 3
:unused-excluded-var 2
:unused-namespace 2
:unused-private-var 6
:unused-value 1}
:config-counts {:constant-condition 1
:deprecated-namespace 7
:deprecated-var 16

View File

@@ -34,6 +34,7 @@ disabled_ratchets=';; Kondo ignore ratchets apply only to master; this release b
# divergent ratchets ratchets (on a pty) none finish without opening an editor
# active ratchets unrelated file app.txt leave the conflict for manual resolution
# divergent ratchets ratchets + app.txt app.txt resolve only ratchets
# active ratchets test ratchets only none disable only the test ratchets file
# release cut ratchets none cut and backport commit the same opt-out
# n/a cut-release workflow n/a the workflow calls the shared writer
# any invalid commit n/a propagate the cherry-pick failure
@@ -155,29 +156,52 @@ test_mixed_conflict_resolves_only_ratchets() (
assert_cherry_pick_pending
)
test_test_ratchets_only_backport_disables_only_that_file() (
local repo="$test_root/test-ratchet-only"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_test_ratchets)
git -C "$repo" checkout -q release
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets "$active_ratchets"
assert_test_ratchets_disabled
assert_cherry_pick_completed
assert_worktree_clean
)
test_release_cut_and_backport_commit_the_same_opt_out() (
local cut_repo="$test_root/release-cut"
init_repo "$cut_repo"
cd "$cut_repo"
git checkout -qb release-x.99.x
write_disabled_ratchets
git add --sparse .clj-kondo/ratchets.edn
write_disabled_ratchets .clj-kondo/ratchets.edn
write_disabled_ratchets .clj-kondo/ratchets-test.edn
git add --sparse .clj-kondo/ratchets.edn .clj-kondo/ratchets-test.edn
git commit -q --allow-empty --no-verify -m "Cut release-x.99.x branch"
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/release-cut.edn"
git show HEAD:.clj-kondo/ratchets-test.edn > "$test_root/release-cut-test.edn"
local backport_repo="$test_root/release-cut-backport"
init_repo "$backport_repo"
local commit
commit=$(feature_commit "$backport_repo" change_ratchets)
commit=$(feature_commit "$backport_repo" change_both_ratchets)
git -C "$backport_repo" checkout -q release
cd "$backport_repo"
cherry_pick_backport "$commit"
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/backport.edn"
git show HEAD:.clj-kondo/ratchets-test.edn > "$test_root/backport-test.edn"
cmp -s "$test_root/release-cut.edn" "$test_root/backport.edn" ||
fail "the release cut and the backport committed different opt-out files"
fail "the release cut and the backport committed different prod opt-out files"
cmp -s "$test_root/release-cut-test.edn" "$test_root/backport-test.edn" ||
fail "the release cut and the backport committed different test opt-out files"
assert_eq "$disabled_ratchets" "$(cat "$test_root/release-cut.edn")" \
"the release cut did not install the expected opt-out"
"the release cut did not install the expected prod opt-out"
assert_eq "$disabled_ratchets" "$(cat "$test_root/release-cut-test.edn")" \
"the release cut did not install the expected test opt-out"
)
test_cut_release_workflow_uses_the_shared_writer() (
@@ -253,6 +277,14 @@ assert_ratchets_disabled() {
assert_ratchets "$disabled_ratchets"
}
assert_test_ratchets() {
assert_eq "$1" "$(cat .clj-kondo/ratchets-test.edn)" "unexpected test-ratchet state"
}
assert_test_ratchets_disabled() {
assert_test_ratchets "$disabled_ratchets"
}
assert_cherry_pick_completed() {
! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick is still pending"
}
@@ -285,6 +317,7 @@ init_repo() {
printf 'base\n' > "$repo/app.txt"
mkdir -p "$repo/.clj-kondo"
printf '%s\n' "$active_ratchets" > "$repo/.clj-kondo/ratchets.edn"
printf '%s\n' "$active_ratchets" > "$repo/.clj-kondo/ratchets-test.edn"
git -C "$repo" add .
git -C "$repo" commit -qm "Base"
}
@@ -317,6 +350,15 @@ change_ratchets() {
printf '{:ignore-counts {:example 2}}\n' > "$1/.clj-kondo/ratchets.edn"
}
change_test_ratchets() {
printf '{:ignore-counts {:example 2}}\n' > "$1/.clj-kondo/ratchets-test.edn"
}
change_both_ratchets() {
change_ratchets "$1"
change_test_ratchets "$1"
}
change_app_and_ratchets() {
change_app "$1"
change_ratchets "$1"
@@ -335,9 +377,9 @@ change_release_app_and_ratchets() {
change_release_ratchets "$1"
}
# Setup writes the opt-out through the shared writer; the only copy is the expected text at the top.
# Setup writes the opt-out through the shared writer; the only copies are the expected text at the top.
disable_release_ratchets() {
(cd "$1" && write_disabled_ratchets)
(cd "$1" && write_disabled_ratchets .clj-kondo/ratchets.edn && write_disabled_ratchets .clj-kondo/ratchets-test.edn)
}
commit_release_change() {
@@ -369,6 +411,7 @@ run_test "ratchet-only conflict is resolved"
run_test "continuation never opens an editor"
run_test "non-ratchet conflict remains manual"
run_test "mixed conflict resolves only ratchets"
run_test "test ratchets only backport disables only that file"
run_test "release cut and backport commit the same opt-out"
run_test "cut-release workflow uses the shared writer"
run_test "invalid commit failure is propagated"

View File

@@ -5,22 +5,28 @@ set -euo pipefail
# The only place that knows what a release branch's opt-out looks like. The cut-release workflow, the
# backport helper, and the tests all go through this function.
write_disabled_ratchets() {
mkdir -p .clj-kondo
local file=$1
mkdir -p "$(dirname "$file")"
printf '%s\n' \
';; Kondo ignore ratchets apply only to master; this release branch opts out.' \
';; The test and fixer recognize this explicit opt-out.' \
'{:disabled true}' \
> .clj-kondo/ratchets.edn
> "$file"
}
disable_ratchets() {
local commit=$1
local file=.clj-kondo/ratchets.edn
[ -n "$(git diff-tree --root --no-commit-id --name-only -r "$commit" -- "$file")" ] || return 0
local file
local disabled_any=
for file in .clj-kondo/ratchets.edn .clj-kondo/ratchets-test.edn; do
[ -n "$(git diff-tree --root --no-commit-id --name-only -r "$commit" -- "$file")" ] || continue
echo "$file: disabling kondo ratchets on the release branch"
write_disabled_ratchets
git add -- "$file"
echo "$file: disabling kondo ratchets on the release branch"
write_disabled_ratchets "$file"
git add -- "$file"
disabled_any=1
done
[ -n "$disabled_any" ] || return 0
if ! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null; then
if ! git diff --cached --quiet HEAD; then
@@ -30,7 +36,7 @@ disable_ratchets() {
fi
if [ -z "$(git ls-files -u)" ]; then
echo "$file was the only conflict, finishing the cherry-pick"
echo "disabled ratchet file(s) were the only conflict, finishing the cherry-pick"
if git diff --cached --quiet HEAD; then
echo "nothing left to apply, committing the backport empty"
git commit --allow-empty --no-edit

View File

@@ -152,7 +152,7 @@ backend_sources: &backend_sources
# Ratchet files drive project_ratchet_checks alone, so automation PRs that lower budgets
# run the Project tests job by themselves. Patterns are OR-ed, so a separate "!..." entry would match
# every other path instead of excluding this one.
- ".clj-kondo/**/!(ratchets.edn)"
- ".clj-kondo/**/!(ratchets.edn|ratchets-test.edn)"
backend_specs: &backend_specs
- *shared_specs
@@ -203,6 +203,7 @@ project_backend_checks:
# Only the ratchet check reads the budgets, so a diff that touches nothing else runs that suite alone.
project_ratchet_checks:
- ".clj-kondo/ratchets.edn"
- ".clj-kondo/ratchets-test.edn"
- ".clj-kondo/config/modules/ratchets.edn"
project_migration_checks:

View File

@@ -49,6 +49,7 @@ describe("file-paths.yaml", () => {
"%s skips a ratchets-only change and still follows the rest of .clj-kondo",
(name) => {
expect(matches(name, ".clj-kondo/ratchets.edn")).toBe(false);
expect(matches(name, ".clj-kondo/ratchets-test.edn")).toBe(false);
expect(matches(name, ".clj-kondo/config/modules/ratchets.edn")).toBe(
false,
);
@@ -94,6 +95,9 @@ describe("file-paths.yaml", () => {
expect(matches("project_ratchet_checks", ".clj-kondo/ratchets.edn")).toBe(
true,
);
expect(
matches("project_ratchet_checks", ".clj-kondo/ratchets-test.edn"),
).toBe(true);
expect(
matches(
"project_ratchet_checks",

View File

@@ -61,10 +61,11 @@ jobs:
# to upload the markdown to #writing.
#
# Kondo ignore ratchets apply only to master. The cut commit replaces the
# budgets with an explicit opt-out so future budget changes are not applied
# to the release branch. Keeping the opt-out in the canonical file avoids
# introducing a separate marker that could drift out of sync, and the
# backport helper writes the same opt-out so both paths stay identical.
# budgets in both ratchet files with an explicit opt-out so future budget
# changes are not applied to the release branch. Keeping the opt-out in the
# canonical files avoids introducing a separate marker that could drift out
# of sync, and the backport helper writes the same opt-out so both paths
# stay identical.
- name: Cut the branch
id: cut
run: |
@@ -73,8 +74,9 @@ jobs:
git config user.email github-actions@github.com
git checkout -b "$BRANCH"
source .github/actions/create-backport/kondo-ratchets.sh
write_disabled_ratchets
git add --sparse .clj-kondo/ratchets.edn
write_disabled_ratchets .clj-kondo/ratchets.edn
write_disabled_ratchets .clj-kondo/ratchets-test.edn
git add --sparse .clj-kondo/ratchets.edn .clj-kondo/ratchets-test.edn
git commit --allow-empty --no-verify -m "Cut $BRANCH branch"
git push -u origin "$BRANCH"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

View File

@@ -33,6 +33,7 @@ concurrency:
env:
AUTOMATION_BRANCH: automation/shrink-ratchets
KONDO_RATCHETS_FILE: .clj-kondo/ratchets.edn
TEST_KONDO_RATCHETS_FILE: .clj-kondo/ratchets-test.edn
MODULE_RATCHETS_FILE: .clj-kondo/config/modules/ratchets.edn
jobs:
@@ -61,6 +62,7 @@ jobs:
run: |
cp "$KONDO_RATCHETS_FILE" "$RUNNER_TEMP/kondo-ratchets-before.edn"
cp "$MODULE_RATCHETS_FILE" "$RUNNER_TEMP/module-ratchets-before.edn"
cp "$TEST_KONDO_RATCHETS_FILE" "$RUNNER_TEMP/test-kondo-ratchets-before.edn"
./bin/mage kondo-ratchets-shrink
# Two independent outputs: `push` says the branch needs a new commit, `pr` names the open automation
# PR if there is one. Approval and auto-merge key on `pr` alone, so a run that failed after opening
@@ -79,7 +81,7 @@ jobs:
--jq '.[0].number // empty')
echo "pr=$open_pr" >> "$GITHUB_OUTPUT"
if git diff --quiet -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"; then
if git diff --quiet -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" "$TEST_KONDO_RATCHETS_FILE"; then
echo "Ratchet budgets already match master $(git rev-parse --short HEAD)"
[ -z "$open_pr" ] || echo "The open automation PR #$open_pr is re-approved below"
echo "push=false" >> "$GITHUB_OUTPUT"
@@ -95,7 +97,7 @@ jobs:
# lazily, so a pending UNKNOWN reads as not conflicted and the next run settles it.
if [ -n "$open_pr" ] \
&& git fetch --quiet --depth=1 origin "refs/heads/$AUTOMATION_BRANCH" 2>/dev/null \
&& git diff --quiet FETCH_HEAD -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" \
&& git diff --quiet FETCH_HEAD -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" "$TEST_KONDO_RATCHETS_FILE" \
&& [ "$(gh pr view "$open_pr" --repo "$GITHUB_REPOSITORY" \
--json mergeable --jq .mergeable)" != CONFLICTING ]; then
echo "The automation PR #$open_pr already carries this result"
@@ -104,7 +106,7 @@ jobs:
exit 0
fi
git diff --stat -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"
git diff --stat -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" "$TEST_KONDO_RATCHETS_FILE"
echo "push=true" >> "$GITHUB_OUTPUT"
- name: Update the automation PR
id: deliver
@@ -122,12 +124,14 @@ jobs:
"$KONDO_RATCHETS_FILE" \
"$RUNNER_TEMP/module-ratchets-before.edn" \
"$MODULE_RATCHETS_FILE" \
"$RUNNER_TEMP/test-kondo-ratchets-before.edn" \
"$TEST_KONDO_RATCHETS_FILE" \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
> "$RUNNER_TEMP/ratchets-pr-body.md"
git config user.name github-actions
git config user.email github-actions@github.com
git add -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"
git add -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" "$TEST_KONDO_RATCHETS_FILE"
git commit --quiet -m "Tighten ratchets"
git push --force --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \

1
.gitignore vendored
View File

@@ -159,6 +159,7 @@ mise.local.toml
!**/.clj-kondo/README.md
!**/.clj-kondo/config.edn
!**/.clj-kondo/ratchets.edn
!**/.clj-kondo/ratchets-test.edn
!**/.clj-kondo/config/
!**/.clj-kondo/src/
!**/.clj-kondo/test/

View File

@@ -104,9 +104,16 @@ interactive explorer with a dependency graph.
After changing Clojure suppressions or module-boundary escape hatches, run `./bin/mage kondo-ratchets`.
Fix the underlying issue when possible; suppressions are a last resort and need a nearby explanation.
Inline-ignore budgets (`:ignore-counts`) are split by prod/test so one side's suppressions can never mask
or look equivalent to the other's: `.clj-kondo/ratchets.edn` covers production code, and
`.clj-kondo/ratchets-test.edn` covers anything under a `test` directory. `:config-counts`
(`.clj-kondo/config.edn` suppressions) stays combined in `.clj-kondo/ratchets.edn` regardless -- see that
file's own header for why. `.clj-kondo/config/modules/ratchets.edn` (module-boundary escape hatches) is a
separate, unsplit concern.
Explain budget increases in the PR. Do not record reductions in feature PRs: post-merge automation opens
a `Tighten ratchets` PR for them. Use `./bin/mage kondo-ratchets-shrink --seed :linter` only when adding an
inline-ignore budget. If either ratchet file conflicts, run `./bin/merge-kondo-ratchets`.
inline-ignore budget. If any ratchet file conflicts, run `./bin/merge-kondo-ratchets`.
## Tool Preferences

31
bb.edn
View File

@@ -433,7 +433,7 @@
-ratchets-merge
{:doc "Internal three-way merge for ratchet files. Called by bin/merge-kondo-ratchets."
:arg-schema [:tuple
[:enum {:name "kind"} "kondo" "modules"]
[:enum {:name "kind"} "kondo" "test" "modules"]
[:string {:name "base" :description "Ratchets file from the merge base"}]
[:string {:name "ours" :description "Git stage 2: ratchets file from the branch receiving the changes"}]
[:string {:name "theirs" :description "Git stage 3: ratchets file from the commits being applied"}]
@@ -453,10 +453,11 @@
(spit output-path
(dev.kondo-ratchet/render-module-ratchets
(dev.kondo-ratchet/merge-module-ratchets base ours theirs)))
(let [merged (dev.kondo-ratchet/merge-ratchets base ours theirs)]
(let [merged (dev.kondo-ratchet/merge-ratchets base ours theirs)
render (if (= kind "test") dev.kondo-ratchet/render-test dev.kondo-ratchet/render)]
(spit output-path (if (dev.kondo-ratchet/disabled? merged)
(slurp ours-path)
(dev.kondo-ratchet/render merged)))))
(render merged)))))
(println (str "merged ratchets into " output-path))))}
-kondo-ratchets-pr-body
@@ -466,21 +467,21 @@
[:string {:name "after" :description "Ratchets file after shrinking"}]
[:string {:name "modules-before" :description "Module ratchets before shrinking"}]
[:string {:name "modules-after" :description "Module ratchets after shrinking"}]
[:string {:name "test-before" :description "Test ratchets file before shrinking"}]
[:string {:name "test-after" :description "Test ratchets file after shrinking"}]
[:string {:name "workflow-url" :description "URL of the workflow run"}]]
:requires [[dev.kondo-ratchet]]
:task (task!
(let [[before-path after-path modules-before-path modules-after-path workflow-url] (:arguments parsed)
read-ratchets (fn [path]
(binding [dev.kondo-ratchet/*ratchets-file* path]
(dev.kondo-ratchet/read-ratchets)))
read-modules (fn [path]
(binding [dev.kondo-ratchet/*module-ratchets-file* path]
(dev.kondo-ratchet/read-module-ratchets)))]
(print (dev.kondo-ratchet/shrink-pr-body (assoc (read-ratchets before-path)
:module-counts (read-modules modules-before-path))
(assoc (read-ratchets after-path)
:module-counts (read-modules modules-after-path))
workflow-url))))}
(let [[before after modules-before modules-after test-before test-after workflow-url]
(:arguments parsed)]
(print (dev.kondo-ratchet/shrink-pr-body-from-files
{:before before
:after after
:modules-before modules-before
:modules-after modules-after
:test-before test-before
:test-after test-after}
workflow-url))))}
-driver-decisions
{:doc "Determine which driver tests should run based on PR context. Outputs decisions for all drivers."

View File

@@ -11,6 +11,7 @@ cd -- "$(git rev-parse --show-toplevel)"
ratchet_files=(
.clj-kondo/ratchets.edn
.clj-kondo/ratchets-test.edn
.clj-kondo/config/modules/ratchets.edn
)
conflicted_files=()
@@ -28,7 +29,7 @@ for ratchets_file in "${ratchet_files[@]}"; do
done
if [ "${#conflicted_files[@]}" -eq 0 ]; then
echo "neither ratchet file is conflicted" >&2
echo "no ratchet file is conflicted" >&2
exit 1
fi
@@ -47,11 +48,11 @@ for i in "${!conflicted_files[@]}"; do
printf '{}\n' > "$tmp_dir/base-$i"
fi
if [ "$ratchets_file" = .clj-kondo/ratchets.edn ]; then
kind=kondo
else
kind=modules
fi
case $ratchets_file in
.clj-kondo/config/modules/ratchets.edn) kind=modules ;;
.clj-kondo/ratchets-test.edn) kind=test ;;
*) kind=kondo ;;
esac
"$bin_dir/mage" -ratchets-merge "$kind" \
"$tmp_dir/base-$i" \
"$tmp_dir/ours-$i" \

View File

@@ -15,9 +15,17 @@
#_{:clj-kondo/ignore [:metabase/discourage-dynamic-vars]}
(def ^:dynamic *ratchets-file*
"Kondo budgets, relative to the repo root. Rebind to read a merge stage or test fixture."
"Kondo budgets for production code, relative to the repo root. Rebind to read a merge stage or test
fixture."
".clj-kondo/ratchets.edn")
#_{:clj-kondo/ignore [:metabase/discourage-dynamic-vars]}
(def ^:dynamic *test-ratchets-file*
"Kondo budgets for test code, relative to the repo root. Separate from [[*ratchets-file*]] so test-code
ignores can't mask a prod-code count; only :ignore-counts is tracked here (see [[config-suppressions]]
for why). Rebind to read a merge stage or test fixture."
".clj-kondo/ratchets-test.edn")
#_{:clj-kondo/ignore [:metabase/discourage-dynamic-vars]}
(def ^:dynamic *module-ratchets-file*
"Module-boundary budgets, relative to the repo root. Rebind to read a merge stage or test fixture."
@@ -262,7 +270,8 @@
(str/join ", " (sort-by str external-linters))))
(defn validate-linters!
"Throw one error naming every policy key in `ratchets` that is not a known linter."
"Throw one error naming every policy key in `ratchets` that is not a known linter. Names
[[*ratchets-file*]] in the message, so call this with it bound to whichever file `ratchets` came from."
[ratchets known]
(let [unknown (unknown-linters ratchets known)]
(when (seq unknown)
@@ -274,6 +283,28 @@
(known-linter-hint))
{:unknown (vec unknown)})))))
(defn- validate-test-config-counts!
"Throw unless `test-ratchets` has an empty :config-counts -- config-level suppressions are tracked only
in `prod-file` (see [[config-suppressions]]), and [[fix!]] silently drops anything here otherwise.
Call with [[*ratchets-file*]] bound to [[*test-ratchets-file*]], so the message names the right file."
[test-ratchets prod-file]
(when (seq (:config-counts test-ratchets))
(throw (ex-info (format "%s must not set :config-counts -- config-level suppressions are tracked only in %s"
*ratchets-file* prod-file)
{:file *ratchets-file*}))))
(defn- read-validated-test-ratchets
"[[read-ratchets]] for [[*test-ratchets-file*]], validated against `known` linters and for a nonempty
:config-counts -- both skipped when the file is `{:disabled true}`. `prod-file` should be
[[*ratchets-file*]]'s value from before the call, so errors name the right file."
[known prod-file]
(binding [*ratchets-file* *test-ratchets-file*]
(let [test-ratchets (read-ratchets)]
(when-not (disabled? test-ratchets)
(validate-linters! test-ratchets known)
(validate-test-config-counts! test-ratchets prod-file))
test-ratchets)))
(defn validate-seed!
"Throw when a linter in `seeded` is not known, so `--seed` never writes a policy the check rejects."
[seeded known]
@@ -484,6 +515,13 @@
:linters (:linters m)
:justified? (:justified? m)})))
(defn test-occurrence?
"Does `occurrence` (as returned by [[scan]]) sit under a directory literally named `test`
(`test/`, `enterprise/backend/test/`, `modules/drivers/*/test/`, ...)? Decides whether its budget
belongs to [[*ratchets-file*]] or [[*test-ratchets-file*]]."
[{:keys [file]}]
(boolean (some #{"test"} (str/split file #"/"))))
(defn actual-counts
"Per-linter occurrence counts for `occurrences`, as returned by [[scan]]."
[occurrences]
@@ -566,7 +604,14 @@
"Per-linter counts of config-level suppressions in `config` (default: `.clj-kondo/config.edn`):
top-level `:linters` entries, `:config-in-comment`, and every `:config-in-ns` / `:config-in-call`
group. Scoped groups count too, or a linter could be switched off inside one and never show up.
Entries that add discouragements or turn linters on count nothing — only weakening counts."
Entries that add discouragements or turn linters on count nothing — only weakening counts.
Tracked only in [[*ratchets-file*]], not split per test/prod like :ignore-counts is: `config.edn`'s
`test-namespaces`/`source-namespaces` groups separate cleanly, but others (e.g. `driver-namespaces`)
cover both, so there's no reliable per-suppression attribution without restructuring `config.edn`.
Doing that later would mean splitting those mixed groups apart, changing this function to track which
group an entry came from instead of summing by linter name, and top-level `:linters`/`:config-in-comment`
entries would still have no test/prod home -- real effort for a softer guarantee than :ignore-counts."
([]
(config-suppressions (edn/read-string (slurp kondo-config-file))))
([config]
@@ -601,10 +646,8 @@
:when (> actual recorded)]
[linter entry])))
(def ^:private header
(str ";; Budgets for kondo suppressions: inline `" ignore-marker "` forms per linter (:ignore-counts), and\n"
";; config-level waivers in .clj-kondo/config.edn (:config-counts -- :off switches and :exclude entries).\n"
";; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling.\n"
(def ^:private shared-header
(str ";; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling.\n"
";; Checks fail when a count exceeds its numeric budget; unused budget is allowed.\n"
";; Any ignore outside :comment-exempt needs an explanatory comment directly above or trailing on its line.\n"
";; `./bin/mage kondo-ratchets-shrink` lowers budgets unless `--seed` explicitly adds or raises one.\n"
@@ -613,6 +656,20 @@
";; exemptions must be explained in the PR.\n"
";; :all is the vector-less ignore form, which suppresses every linter on the next form.\n"))
(def ^:private header
(str ";; Budgets for kondo suppressions: inline `" ignore-marker "` forms per linter (:ignore-counts), and\n"
";; config-level waivers in .clj-kondo/config.edn (:config-counts -- :off switches and :exclude entries).\n"
shared-header
";;\n"
";; Test code's :ignore-counts has its own budget in .clj-kondo/ratchets-test.edn.\n"
";; :config-counts stays here either way -- config.edn's scoping doesn't cleanly split.\n"))
(def ^:private test-header
(str ";; Budgets for kondo suppressions in test code: inline `" ignore-marker "` forms per linter\n"
";; (:ignore-counts). Production code has its own budgets in .clj-kondo/ratchets.edn, which also holds\n"
";; every config-level waiver (:config-counts) -- config.edn's scoping doesn't cleanly split.\n"
shared-header))
(def ^:private module-header
(str ";; Budgets for escape hatches in config.edn. Counts may fall but not exceed these values.\n"
";; Leave reductions to the post-merge shrink workflow; explain increases in the PR.\n"))
@@ -629,14 +686,14 @@
(format (str "%-" width "s %s") (str linter) (str n))))
"}"))))
(defn render
"Canonical Kondo ratchet-file text."
[{:keys [ignore-counts config-counts comment-exempt]}]
(defn- render-policies
[header {:keys [ignore-counts config-counts comment-exempt]} config-counts?]
(let [counts-indent (apply str (repeat (count "{:ignore-counts {") \space))
exempt-indent (apply str (repeat (count " :comment-exempt #{") \space))]
(str header
"{:ignore-counts " (render-counts ignore-counts counts-indent)
"\n :config-counts " (render-counts config-counts counts-indent)
(when config-counts?
(str "\n :config-counts " (render-counts config-counts counts-indent)))
"\n :comment-exempt "
(if (empty? comment-exempt)
"#{}"
@@ -646,6 +703,21 @@
"}"))
"}\n")))
(defn render
"Canonical text for the prod ratchets file, [[*ratchets-file*]]."
[ratchets]
(render-policies header ratchets true))
(defn render-test
"Canonical text for the test ratchets file, [[*test-ratchets-file*]], which has no :config-counts.
Throws on a nonempty :config-counts rather than dropping it."
[{:keys [config-counts] :as ratchets}]
(when (seq config-counts)
(throw (ex-info (str *test-ratchets-file* " must not set :config-counts -- config-level suppressions are"
" tracked only in " *ratchets-file*)
{:config-counts config-counts})))
(render-policies test-header ratchets false))
(defn render-module-ratchets
"Canonical module ratchet-file text."
[ratchets]
@@ -782,11 +854,13 @@
(str "WARNING: :comment-exempt is no longer needed for these linters: " (str/join ", " linters)
" -- delete the stale entries by hand"))))
(defn change-report
"The lines [[fix!]] prints for budget changes, budget violations, unused `:unlimited` policies, and
stale comment exemptions."
[{:keys [ignore-counts config-counts comment-exempt]} module-ratchets
occurrences config-actual module-actual seeded]
(defn- ignore-change-report
"Change-report lines for one file's :ignore-counts, scoped to `occurrences`: seeding, lowered/dropped/
over-budget counts, unused `:unlimited` policies, and ignores with no budget entry. Its :comment-exempt
is reported separately, via [[stale-exemptions-warning]], so callers can place it relative to other
sections (e.g. after config/module counts, as [[change-report]] does).
Shared by [[change-report]] (the prod ratchets file) and [[fix!]]'s pass over the test ratchets file."
[ignore-counts occurrences seeded]
(let [actual (actual-counts occurrences)]
(concat
(for [linter seeded
@@ -809,30 +883,39 @@
(some-> (unexercised-unlimited-warning (apply dissoc ignore-counts seeded) actual) vector)
(for [[linter n] (sort-by (comp str first) (apply dissoc actual (concat seeded (keys ignore-counts))))]
(format "WARNING: %s has %d ignores but no budget entry -- seed one with `./bin/mage kondo-ratchets-shrink --seed %s`"
linter n linter))
(for [[linter {:keys [recorded actual]}] (count-drift config-counts config-actual)]
(cond
(zero? actual) (format "dropped config %s (no suppressions left)" linter)
(< actual recorded) (format "lowered config %s %d -> %d" linter recorded actual)
:else (format "WARNING: config suppressions for %s are over budget (%d recorded, %d actual) -- remove one from .clj-kondo/config.edn or raise the budget by hand"
linter recorded actual)))
(for [[metric {:keys [recorded actual]}] (count-drift module-ratchets module-actual)]
(cond
(zero? actual) (format "dropped module %s (no escape hatches left)" metric)
(< actual recorded) (format "lowered module %s %d -> %d" metric recorded actual)
:else (format (str "WARNING: module %s is over budget (%d recorded, %d actual)"
" -- remove one from " module-config-file " or raise the budget by hand")
metric recorded actual)))
(some-> (stale-exemptions-warning comment-exempt occurrences) vector))))
linter n linter)))))
(defn change-report
"The lines [[fix!]] prints for budget changes, budget violations, unused `:unlimited` policies, and
stale comment exemptions."
[{:keys [ignore-counts config-counts comment-exempt]} module-ratchets
occurrences config-actual module-actual seeded]
(concat
(ignore-change-report ignore-counts occurrences seeded)
(for [[linter {:keys [recorded actual]}] (count-drift config-counts config-actual)]
(cond
(zero? actual) (format "dropped config %s (no suppressions left)" linter)
(< actual recorded) (format "lowered config %s %d -> %d" linter recorded actual)
:else (format "WARNING: config suppressions for %s are over budget (%d recorded, %d actual) -- remove one from .clj-kondo/config.edn or raise the budget by hand"
linter recorded actual)))
(for [[metric {:keys [recorded actual]}] (count-drift module-ratchets module-actual)]
(cond
(zero? actual) (format "dropped module %s (no escape hatches left)" metric)
(< actual recorded) (format "lowered module %s %d -> %d" metric recorded actual)
:else (format (str "WARNING: module %s is over budget (%d recorded, %d actual)"
" -- remove one from " module-config-file " or raise the budget by hand")
metric recorded actual)))
(some-> (stale-exemptions-warning comment-exempt occurrences) vector)))
(def ^:private count-fields
[:ignore-counts :config-counts :module-counts])
[:ignore-counts :config-counts :module-counts :test-ignore-counts])
(defn- display-name
[field linter]
(case field
:config-counts (str ":config/" (subs (str linter) 1))
:module-counts (str ":module/" (subs (str linter) 1))
:config-counts (str ":config/" (subs (str linter) 1))
:module-counts (str ":module/" (subs (str linter) 1))
:test-ignore-counts (str ":test/" (subs (str linter) 1))
(str linter)))
(defn- shrink-changes
@@ -898,12 +981,31 @@
"Generated by [`./bin/mage kondo-ratchets-shrink`](" workflow-url
")\n"))
(defn- read-ratchet-snapshots
"The budgets in a prod, module, and test ratchet file triple, combined into the shape
[[shrink-summary]] compares."
[ratchets-path module-ratchets-path test-ratchets-path]
(let [read-ratchets* #(binding [*ratchets-file* %] (read-ratchets))]
(assoc (read-ratchets* ratchets-path)
:module-counts (binding [*module-ratchets-file* module-ratchets-path]
(read-module-ratchets))
:test-ignore-counts (:ignore-counts (read-ratchets* test-ratchets-path)))))
(defn shrink-pr-body-from-files
"[[shrink-pr-body]] for ratchet files on disk, before and after shrinking."
[{:keys [before after modules-before modules-after test-before test-after]} workflow-url]
(shrink-pr-body (read-ratchet-snapshots before modules-before test-before)
(read-ratchet-snapshots after modules-after test-after)
workflow-url))
(defn fix!
"Lower budgets and normalize both ratchet files.
"Lower budgets and normalize the prod, test, and module ratchet files.
Refuses to touch a file containing an unknown linter or to seed one, rather than silently dropping it.
`--seed LINTER` (`{:seed \"...\"}` here) sets that budget to the actual count and bounds an unlimited one.
Prints the [[change-report]], or `unchanged` on a no-op.
Does nothing, including seeding, when the file sets `:disabled` to `true`."
`--seed LINTER` (`{:seed \"...\"}` here) sets that budget to the actual count and bounds an unlimited
one, independently in whichever file(s) actually have occurrences for it.
Prints a [[change-report]] per file, or `unchanged` on a no-op.
Does nothing, including seeding, when [[*ratchets-file*]] sets `:disabled` to `true`; the test ratchets
file honors its own `:disabled` independently, skipping only its own pass."
([]
(fix! nil))
([{:keys [seed]}]
@@ -913,35 +1015,48 @@
(let [module-ratchets (read-module-ratchets)
known (known-linters)
_ (validate-linters! ratchets known)
test-ratchets (read-validated-test-ratchets known *ratchets-file*)
test-disabled? (disabled? test-ratchets)
seeded (if seed [(keyword (str/replace-first seed #"^:" ""))] [])
_ (validate-seed! seeded known)
occurrences (scan)
actual (actual-counts occurrences)
{test-occ true, prod-occ false} (group-by test-occurrence? occurrences)
actual (actual-counts prod-occ)
test-actual (actual-counts test-occ)
config-actual (config-suppressions)
module-actual (module-escape-hatches)
outputs [[*ratchets-file*
(render {:ignore-counts (lowered-counts ignore-counts actual seeded)
:config-counts (lowered-counts config-counts config-actual [])
:comment-exempt comment-exempt})]
[*module-ratchets-file*
(render-module-ratchets
(lowered-counts module-ratchets module-actual []))]]
outputs (cond-> [[*ratchets-file*
(render {:ignore-counts (lowered-counts ignore-counts actual seeded)
:config-counts (lowered-counts config-counts config-actual [])
:comment-exempt comment-exempt})]
[*module-ratchets-file*
(render-module-ratchets
(lowered-counts module-ratchets module-actual []))]]
(not test-disabled?)
(conj [*test-ratchets-file*
(render-test {:ignore-counts (lowered-counts (:ignore-counts test-ratchets)
test-actual seeded)
:comment-exempt (:comment-exempt test-ratchets)})]))
changed (filterv (fn [[path text]] (not= (slurp path) text)) outputs)]
(run! println (change-report ratchets module-ratchets occurrences config-actual module-actual seeded))
(run! println (change-report ratchets module-ratchets prod-occ config-actual module-actual seeded))
(when-not test-disabled?
(run! println (concat (ignore-change-report (:ignore-counts test-ratchets) test-occ seeded)
(some-> (stale-exemptions-warning (:comment-exempt test-ratchets) test-occ)
vector))))
(if (empty? changed)
(println "unchanged")
(doseq [[path text] changed]
(spit path text)
(println (str "wrote " path)))))))))
(defn check-report
"Diagnostics for budget violations, unjustified ignores, and noncanonical ratchet text.
Lower counts are valid; post-merge automation records them."
[{:keys [ignore-counts config-counts comment-exempt] :or {comment-exempt #{}} :as ratchets}
module-ratchets occurrences config-actual module-actual ratchets-text module-ratchets-text]
(defn- ignore-check-lines
"Budget, justification, and formatting diagnostics for one ratchets file's :ignore-counts and
:comment-exempt, scoped to `occurrences`. `ratchets-file` names the file in the not-normalized message, and
`render-fn` gives its canonical text.
Shared by [[check-report]] (the prod ratchets file) and [[check]]'s pass over the test ratchets file."
[ratchets-file render-fn {:keys [ignore-counts comment-exempt] :or {comment-exempt #{}} :as ratchets}
occurrences ratchets-text]
(let [over (over-budget ignore-counts occurrences)
config-over (counts-over-budget config-counts config-actual)
module-over (counts-over-budget module-ratchets module-actual)
uncommented (unjustified comment-exempt occurrences)
budget-line (fn [[k {:keys [recorded actual]}]]
(format " %s: %d recorded, %d actual" k recorded actual))]
@@ -952,6 +1067,27 @@
(mapcat (fn [[_ {:keys [examples]} :as entry]]
(cons (budget-line entry) (map #(str " " %) examples)))
over)))
(when (seq uncommented)
(cons (str "ignores without required comments -- add a `;;` comment above the form or at the end"
" of the same line, explaining why the suppression is necessary:")
(map (fn [{:keys [file line linters]}]
(format " %s:%d %s" file line (vec linters)))
uncommented)))
(when (not= ratchets-text (render-fn ratchets))
[(str ratchets-file " is not normalized -- run `./bin/mage kondo-ratchets-shrink`"
" to fix the formatting")]))))
(defn check-report
"Diagnostics for budget violations, unjustified ignores, and noncanonical ratchet text.
Lower counts are valid; post-merge automation records them."
[{:keys [config-counts] :as ratchets}
module-ratchets occurrences config-actual module-actual ratchets-text module-ratchets-text]
(let [config-over (counts-over-budget config-counts config-actual)
module-over (counts-over-budget module-ratchets module-actual)
budget-line (fn [[k {:keys [recorded actual]}]]
(format " %s: %d recorded, %d actual" k recorded actual))]
(concat
(ignore-check-lines *ratchets-file* render ratchets occurrences ratchets-text)
(when (seq config-over)
(cons (str "config suppressions over budget -- remove the entry from " kondo-config-file
", or raise the budget manually and explain the increase in the PR:")
@@ -960,15 +1096,6 @@
(cons (str "module escape hatches over budget -- remove one from " module-config-file
", or raise the budget manually and explain the increase in the PR:")
(map budget-line module-over)))
(when (seq uncommented)
(cons (str "ignores without required comments -- add a `;;` comment above the form or at the end"
" of the same line, explaining why the suppression is necessary:")
(map (fn [{:keys [file line linters]}]
(format " %s:%d %s" file line (vec linters)))
uncommented)))
(when (not= ratchets-text (render ratchets))
[(str *ratchets-file* " is not normalized -- run `./bin/mage kondo-ratchets-shrink`"
" to fix the formatting")])
(when (not= module-ratchets-text (render-module-ratchets module-ratchets))
[(str *module-ratchets-file* " is not normalized -- run `./bin/mage kondo-ratchets-shrink`"
" to fix the formatting")]))))
@@ -985,27 +1112,42 @@
(exit! message))
(defn check
"Validate the ratchet files and current counts. Fail on missing or malformed configuration, unknown
linters, budget violations, or unjustified ignores. Report stale unlimited policies and exemptions
without failing. Only `{:disabled true}` disables enforcement."
"Validate the prod, test, and module ratchet files and current counts. Fail on missing or malformed
configuration, unknown linters, budget violations, or unjustified ignores. Report stale unlimited
policies and exemptions without failing. `{:disabled true}` in [[*ratchets-file*]] disables checking
entirely; the test ratchets file honors its own `:disabled` independently, skipping only its own checks."
[]
;; Turn validation errors into concise Mage output instead of a Babashka stack trace.
(try
(let [ratchets (read-ratchets)]
(if (disabled? ratchets)
(println (str *ratchets-file* " is disabled -- nothing to check"))
(do
(validate-linters! ratchets (known-linters))
(let [known (known-linters)]
(validate-linters! ratchets known)
(let [module-ratchets (read-module-ratchets)
test-ratchets (read-validated-test-ratchets known *ratchets-file*)
test-disabled? (disabled? test-ratchets)
occurrences (scan)
lines (check-report ratchets module-ratchets occurrences
(config-suppressions) (module-escape-hatches)
(slurp *ratchets-file*) (slurp *module-ratchets-file*))]
(some-> (unexercised-unlimited-warning (:ignore-counts ratchets) (actual-counts occurrences)) println)
(some-> (stale-exemptions-warning (:comment-exempt ratchets) occurrences) println)
{test-occ true, prod-occ false} (group-by test-occurrence? occurrences)
lines (concat
(check-report ratchets module-ratchets prod-occ
(config-suppressions) (module-escape-hatches)
(slurp *ratchets-file*) (slurp *module-ratchets-file*))
(when-not test-disabled?
(ignore-check-lines *test-ratchets-file* render-test test-ratchets test-occ
(slurp *test-ratchets-file*))))]
(some-> (unexercised-unlimited-warning (:ignore-counts ratchets) (actual-counts prod-occ)) println)
(some-> (stale-exemptions-warning (:comment-exempt ratchets) prod-occ) println)
(when-not test-disabled?
(some-> (unexercised-unlimited-warning (:ignore-counts test-ratchets) (actual-counts test-occ)) println)
(some-> (stale-exemptions-warning (:comment-exempt test-ratchets) test-occ) println))
(if (empty? lines)
(println (format "ok -- %d ignore forms within %d policies"
(count occurrences) (count (:ignore-counts ratchets))))
(do
(println (format "ok -- %d ignore forms within %d policies"
(count prod-occ) (count (:ignore-counts ratchets))))
(when-not test-disabled?
(println (format "ok -- %d test ignore forms within %d test policies"
(count test-occ) (count (:ignore-counts test-ratchets))))))
(do (run! println lines)
(exit! "ratchet files drifted from the source tree")))))))
(catch clojure.lang.ExceptionInfo e

View File

@@ -90,20 +90,29 @@
(defn- check-with!
"Output lines of [[kondo-ratchet/check]] against `ratchets` written to a temp file, with `occurrences`
standing in for the tree scan; `:thrown?` says whether it failed."
standing in for the tree scan; `:thrown?` says whether it failed. `:test-counts` stands in for the test
ratchets file, defaulting to an empty (clean) budget; `{:disabled true}` there is honored."
[ratchets occurrences]
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-check-test"
(make-array java.nio.file.attribute.FileAttribute 0)))
budgets (doto (io/file dir "ratchets.edn")
(spit (kondo-ratchet/render (merge {:config-counts {}, :comment-exempt #{}}
(dissoc ratchets :module-counts)))))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets (:module-counts ratchets {}))))
thrown? (atom false)]
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-check-test"
(make-array java.nio.file.attribute.FileAttribute 0)))
test-ratchets (:test-counts ratchets {})
budgets (doto (io/file dir "ratchets.edn")
(spit (kondo-ratchet/render (merge {:config-counts {}, :comment-exempt #{}}
(dissoc ratchets :module-counts :test-counts)))))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets (:module-counts ratchets {}))))
test-budgets (doto (io/file dir "ratchets-test.edn")
(spit (if (:disabled test-ratchets)
"{:disabled true}\n"
(kondo-ratchet/render-test (merge {:ignore-counts {}, :comment-exempt #{}}
test-ratchets)))))
thrown? (atom false)]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly (set (keys (:ignore-counts ratchets))))
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly (into (set (keys (:ignore-counts ratchets)))
(keys (:ignore-counts test-ratchets))))
kondo-ratchet/config-suppressions (constantly {})
kondo-ratchet/module-escape-hatches (constantly {})
kondo-ratchet/scan (constantly occurrences)]
@@ -118,7 +127,8 @@
(deftest check-reports-empty-unlimited-test
(let [ratchets {:ignore-counts {:z-empty :unlimited, :a-empty :unlimited, :free :unlimited, :over 1}}]
(is (= {:lines ["WARNING: :unlimited policies with no ignores left: :a-empty, :z-empty -- delete an entry by hand once its linter no longer needs one"
"ok -- 2 ignore forms within 4 policies"]
"ok -- 2 ignore forms within 4 policies"
"ok -- 0 test ignore forms within 0 test policies"]
:thrown? false}
(check-with! ratchets (occurrences {:free 1, :over 1})))
"the warning comes first, sorted, and does not fail the check")
@@ -130,7 +140,8 @@
:thrown? true}
(check-with! ratchets (occurrences {:free 1, :over 2})))
"a real failure still fails, after the warning")
(is (= {:lines ["ok -- 4 ignore forms within 4 policies"]
(is (= {:lines ["ok -- 4 ignore forms within 4 policies"
"ok -- 0 test ignore forms within 0 test policies"]
:thrown? false}
(check-with! ratchets (occurrences {:z-empty 1, :a-empty 1, :free 1, :over 1})))
"no warning when every unlimited policy is in use")))
@@ -141,11 +152,40 @@
occurrences [{:file "f.clj", :line 1, :linters [:grandfathered], :justified? true}
{:file "g.clj", :line 1, :linters [:still-needed], :justified? false}]]
(is (= {:lines ["WARNING: :comment-exempt is no longer needed for these linters: :grandfathered, :none-left -- delete the stale entries by hand"
"ok -- 2 ignore forms within 3 policies"]
"ok -- 2 ignore forms within 3 policies"
"ok -- 0 test ignore forms within 0 test policies"]
:thrown? false}
(check-with! ratchets occurrences))
"exemptions with only commented ignores or no ignores are named, and do not fail the check")))
;;;; ---------------------------------------------------------------------------
;;;; Prod/test split
;;;; ---------------------------------------------------------------------------
(deftest check-splits-prod-and-test-occurrences-test
(let [ratchets {:ignore-counts {:a 1}
:test-counts {:ignore-counts {:a 1}}}
occurrences [{:file "src/f.clj", :line 1, :linters [:a], :justified? true}
{:file "test/g.clj", :line 1, :linters [:a], :justified? true}
{:file "test/h.clj", :line 2, :linters [:a], :justified? true}]]
(is (= {:lines ["over budget -- remove an ignore, or seed the budget with `./bin/mage kondo-ratchets-shrink --seed <linter>` and explain the increase in the PR:"
" :a: 1 recorded, 2 actual"
" test/g.clj:1"
" test/h.clj:2"]
:thrown? true}
(check-with! ratchets occurrences))
"one prod occurrence stays within the prod budget; the two test occurrences alone blow the
test budget, independent of the prod count")))
(deftest check-test-ratchets-disabled-test
(let [ratchets {:ignore-counts {}
:test-counts {:disabled true}}
occurrences [{:file "test/g.clj", :line 1, :linters [:unbudgeted], :justified? true}]]
(is (= {:lines [(str "ok -- 0 ignore forms within 0 policies")], :thrown? false}
(check-with! ratchets occurrences))
"a disabled test-ratchets file opts the test tree out of enforcement, even with an unbudgeted
test-only linter")))
(deftest ^:parallel stale-test
(let [ratchets {:ignore-counts {:a 5, :gone 2}}]
(is (= []
@@ -208,3 +248,69 @@
#"only \{:disabled true\} opts out"
(kondo-ratchet/check))))
"is missing -- only {:disabled true} opts out of enforcement"))))
(deftest check-unknown-linter-in-test-file-names-the-right-file-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-check-test"
(make-array java.nio.file.attribute.FileAttribute 0)))
budgets (doto (io/file dir "ratchets.edn")
(spit (kondo-ratchet/render {:ignore-counts {}, :config-counts {}, :comment-exempt #{}})))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets {})))
test-budgets (doto (io/file dir "ratchets-test.edn")
(spit (kondo-ratchet/render-test {:ignore-counts {:bogus 1}, :comment-exempt #{}})))]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{})]
(let [out (with-out-str
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"names 1 unknown linter: :bogus"
(kondo-ratchet/check))))]
(is (str/includes? out (str (.getPath test-budgets) " names 1 unknown linter: :bogus"))
"the test file is named, not the prod file")
(is (not (str/includes? out (.getPath budgets)))
"the prod file is never mentioned"))))))
(deftest check-rejects-nonempty-test-config-counts-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-check-test"
(make-array java.nio.file.attribute.FileAttribute 0)))
budgets (doto (io/file dir "ratchets.edn")
(spit (kondo-ratchet/render {:ignore-counts {}, :config-counts {}, :comment-exempt #{}})))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets {})))
test-budgets (doto (io/file dir "ratchets-test.edn")
(spit (kondo-ratchet/render {:ignore-counts {}, :config-counts {:a 1}, :comment-exempt #{}})))]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{:a})]
(is (thrown-with-msg? clojure.lang.ExceptionInfo
(re-pattern (str (java.util.regex.Pattern/quote (.getPath test-budgets))
" must not set :config-counts -- config-level suppressions"
" are tracked only in "
(java.util.regex.Pattern/quote (.getPath budgets))))
(kondo-ratchet/check)))))))
(deftest check-flags-a-test-file-in-prod-format-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-check-test"
(make-array java.nio.file.attribute.FileAttribute 0)))
empty-policy {:ignore-counts {}, :config-counts {}, :comment-exempt #{}}
budgets (doto (io/file dir "ratchets.edn") (spit (kondo-ratchet/render empty-policy)))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets {})))
test-budgets (doto (io/file dir "ratchets-test.edn") (spit (kondo-ratchet/render empty-policy)))]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{})
kondo-ratchet/scan (constantly [])
kondo-ratchet/config-suppressions (constantly {})
kondo-ratchet/module-escape-hatches (constantly {})]
(let [out (with-out-str
(is (thrown? clojure.lang.ExceptionInfo (kondo-ratchet/check))))]
(is (= [(str (.getPath test-budgets) " is not normalized -- run `./bin/mage kondo-ratchets-shrink`"
" to fix the formatting")]
(str/split-lines out))
"the prod header and an empty :config-counts are not the test file's canonical text"))))))

View File

@@ -208,6 +208,20 @@
(is (str/ends-with? (kondo-ratchet/render {:ignore-counts {}, :config-counts {}, :comment-exempt #{}})
"{:ignore-counts {}\n :config-counts {}\n :comment-exempt #{}}\n"))))
(deftest ^:parallel render-test-test
(let [ratchets {:ignore-counts {:discouraged-var 3}
:comment-exempt #{:discouraged-var}}
text (kondo-ratchet/render-test (assoc ratchets :config-counts {}))]
(is (str/starts-with? text ";; Budgets for kondo suppressions in test code"))
(is (str/ends-with? text (str "{:ignore-counts {:discouraged-var 3}\n"
" :comment-exempt #{:discouraged-var}}\n"))
"no :config-counts -- only the prod file tracks them")
(is (= ratchets (edn/read-string text)))
(is (= text (kondo-ratchet/render-test (edn/read-string text)))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"must not set :config-counts"
(kondo-ratchet/render-test {:ignore-counts {}, :config-counts {:a 1}, :comment-exempt #{}}))
"a nonempty :config-counts is an error, not silently dropped"))
(deftest ^:parallel render-module-ratchets-test
(let [ratchets {:uses-any 4, :api-any 1}
text (kondo-ratchet/render-module-ratchets ratchets)]
@@ -349,6 +363,13 @@
(with-out-str (kondo-ratchet/fix! {:seed "whatever"})))))
(is (= "{:disabled true}\n" (slurp budgets)))))))
(defn- empty-test-ratchets-file!
"A temp `.clj-kondo/ratchets-test.edn` stand-in, already clean (no budgets) -- so a [[kondo-ratchet/fix!]]
run with no test-path occurrences writes nothing and reports nothing for it."
^java.io.File [dir]
(doto (io/file dir "ratchets-test.edn")
(spit (kondo-ratchet/render-test {:ignore-counts {}, :comment-exempt #{}}))))
(deftest ^:synchronized seed-unlimited-linter-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-test"
@@ -357,17 +378,22 @@
budgets (doto (io/file dir "ratchets.edn") (spit (kondo-ratchet/render ratchets)))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets {})))
test-budgets (empty-test-ratchets-file! dir)
occurrences [{:file "f.clj", :line 1, :linters [:free]}
{:file "f.clj", :line 2, :linters [:free]}]]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)]
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{:free})
kondo-ratchet/scan (constantly occurrences)
kondo-ratchet/config-suppressions (constantly {})
kondo-ratchet/module-escape-hatches (constantly {})]
(is (= ["seeded :free at 2"
"WARNING: :free has no inline ignores -- nothing to seed"
(str "wrote " (.getPath budgets))]
(str/split-lines (with-out-str (kondo-ratchet/fix! {:seed "free"}))))))
(str/split-lines (with-out-str (kondo-ratchet/fix! {:seed "free"}))))
"the prod occurrences seed the prod budget; the seeded linter has none under `test/`, so the
test report says so and the test file is left untouched"))
(is (= {:ignore-counts {:free 2}
:config-counts {}
:comment-exempt #{}}
@@ -380,9 +406,11 @@
text (kondo-ratchet/render {:ignore-counts {:free :unlimited}, :config-counts {}, :comment-exempt #{}})
budgets (doto (io/file dir "ratchets.edn") (spit text))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets {})))]
(spit (kondo-ratchet/render-module-ratchets {})))
test-budgets (empty-test-ratchets-file! dir)]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)]
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{:free})
kondo-ratchet/scan (fn [] (throw (AssertionError. "scanned before validating the seed")))]
(is (thrown-with-msg? clojure.lang.ExceptionInfo
@@ -401,10 +429,12 @@
budgets (doto (io/file dir "ratchets.edn") (spit (kondo-ratchet/render ratchets)))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets {})))
test-budgets (empty-test-ratchets-file! dir)
occurrences [{:file "f.clj", :line 1, :linters [:free]}]
run! #(str/split-lines (with-out-str (kondo-ratchet/fix!)))]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)]
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{:free :empty :gone :zero})
kondo-ratchet/scan (constantly occurrences)
kondo-ratchet/config-suppressions (constantly {})
@@ -434,9 +464,11 @@
budgets (doto (io/file dir "ratchets.edn") (spit (kondo-ratchet/render ratchets)))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets
{:api-any 2, :friend-edges 5, :uses-any 1})))]
{:api-any 2, :friend-edges 5, :uses-any 1})))
test-budgets (empty-test-ratchets-file! dir)]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)]
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{})
kondo-ratchet/scan (constantly [])
kondo-ratchet/config-suppressions (constantly {})
@@ -449,6 +481,80 @@
(is (= {:api-any 1, :friend-edges 4}
(kondo-ratchet/read-module-ratchets)))))))
(deftest ^:synchronized fix-splits-prod-and-test-budgets-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-test"
(make-array java.nio.file.attribute.FileAttribute 0)))
ratchets {:ignore-counts {:a 5}, :config-counts {}, :comment-exempt #{}}
budgets (doto (io/file dir "ratchets.edn") (spit (kondo-ratchet/render ratchets)))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets {})))
test-budgets (doto (io/file dir "ratchets-test.edn")
(spit (kondo-ratchet/render-test (dissoc ratchets :config-counts))))
occurrences [{:file "src/f.clj", :line 1, :linters [:a]}
{:file "src/f.clj", :line 2, :linters [:a]}
{:file "test/g.clj", :line 1, :linters [:a]}]]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{:a})
kondo-ratchet/scan (constantly occurrences)
kondo-ratchet/config-suppressions (constantly {})
kondo-ratchet/module-escape-hatches (constantly {})]
(is (= ["lowered :a 5 -> 2"
"lowered :a 5 -> 1"
(str "wrote " (.getPath budgets))
(str "wrote " (.getPath test-budgets))]
(str/split-lines (with-out-str (kondo-ratchet/fix!))))
"the two src occurrences lower the prod budget to 2; the one test occurrence lowers the test
budget to 1, independently -- neither count masks the other"))
(is (= {:ignore-counts {:a 2}, :config-counts {}, :comment-exempt #{}}
(kondo-ratchet/read-ratchets)))
(is (= {:ignore-counts {:a 1}, :config-counts {}, :comment-exempt #{}}
(binding [kondo-ratchet/*ratchets-file* (.getPath test-budgets)]
(kondo-ratchet/read-ratchets)))))))
(deftest ^:synchronized fix-validates-test-ratchets-against-the-right-file-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-test"
(make-array java.nio.file.attribute.FileAttribute 0)))
budgets (doto (io/file dir "ratchets.edn")
(spit (kondo-ratchet/render {:ignore-counts {}, :config-counts {}, :comment-exempt #{}})))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets {})))
test-budgets (doto (io/file dir "ratchets-test.edn")
(spit (kondo-ratchet/render-test {:ignore-counts {:bogus 1}, :comment-exempt #{}})))]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{})]
(is (thrown-with-msg? clojure.lang.ExceptionInfo
(re-pattern (str "^" (java.util.regex.Pattern/quote (.getPath test-budgets))
" names 1 unknown linter: :bogus"))
(kondo-ratchet/fix! nil))
"the test file is named, not the prod file")))))
(deftest ^:synchronized fix-rejects-nonempty-test-config-counts-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-test"
(make-array java.nio.file.attribute.FileAttribute 0)))
budgets (doto (io/file dir "ratchets.edn")
(spit (kondo-ratchet/render {:ignore-counts {}, :config-counts {}, :comment-exempt #{}})))
modules (doto (io/file dir "module-ratchets.edn")
(spit (kondo-ratchet/render-module-ratchets {})))
test-budgets (doto (io/file dir "ratchets-test.edn")
(spit (kondo-ratchet/render {:ignore-counts {}, :config-counts {:a 1}, :comment-exempt #{}})))]
(binding [kondo-ratchet/*ratchets-file* (.getPath budgets)
kondo-ratchet/*module-ratchets-file* (.getPath modules)
kondo-ratchet/*test-ratchets-file* (.getPath test-budgets)]
(mt/with-dynamic-fn-redefs [kondo-ratchet/known-linters (constantly #{:a})]
(is (thrown-with-msg? clojure.lang.ExceptionInfo
(re-pattern (str (java.util.regex.Pattern/quote (.getPath test-budgets))
" must not set :config-counts -- config-level suppressions"
" are tracked only in "
(java.util.regex.Pattern/quote (.getPath budgets))))
(kondo-ratchet/fix! nil)))))))
(deftest read-ratchets-requires-one-map-test
(doseq [[content message] [["" #"is empty; expected one map"]
[" \n;; only a comment\n" #"is empty; expected one map"]
@@ -830,3 +936,23 @@
"# TODO\n . TODONE"))
(is (str/includes? (body ["# still our problem" ". fixed, so no longer our problem"])
"[`./bin/mage kondo-ratchets-shrink`](https://example.test/run/1)"))))
(deftest ^:synchronized shrink-pr-body-from-files-test
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-test"
(make-array java.nio.file.attribute.FileAttribute 0)))
write (fn [file-name text] (.getPath (doto (io/file dir file-name) (spit text))))
prod (fn [n] (kondo-ratchet/render {:ignore-counts {:a n}, :config-counts {}, :comment-exempt #{}}))
paths {:before (write "before.edn" (prod 3))
:after (write "after.edn" (prod 2))
:modules-before (write "modules-before.edn" (kondo-ratchet/render-module-ratchets {:friend-edges 5}))
:modules-after (write "modules-after.edn" (kondo-ratchet/render-module-ratchets {:friend-edges 4}))
:test-before (write "test-before.edn" (prod 7))
:test-after (write "test-after.edn" (prod 6))}]
(try
(let [body (kondo-ratchet/shrink-pr-body-from-files paths "https://example.test/run/1")]
(is (re-find #":a\s+3 => 2" body))
(is (re-find #":module/friend-edges\s+5 => 4" body))
(is (re-find #":test/a\s+7 => 6" body)))
(finally
(run! io/delete-file (reverse (file-seq dir)))))))

View File

@@ -471,6 +471,31 @@
;;;; kondo-insert-ignores
;;;; ---------------------------------------------------------------------------
(defn- exemption-suggestion
"The `insert-ignores` follow-up message recommending where to add `linter` to :comment-exempt, given its
`occurrences` (already found) -- naming the prod file, the test file, both, or nil when neither needs
it. An existing comment above a flagged form may already justify its ignore, so a file is only named
when the scanner still finds uncommented ignores there that its own :comment-exempt doesn't already
cover; otherwise it is unnecessary and the ratchet check reports it as stale. Prod and test track
:comment-exempt independently, so each is checked against its own file."
[linter occurrences]
(let [{test-occ true, prod-occ false} (group-by kondo-ratchet/test-occurrence? occurrences)
needs-exempt? (fn [ratchets-file occs]
(and (seq occs)
(not (contains? (:comment-exempt (binding [kondo-ratchet/*ratchets-file* ratchets-file]
(kondo-ratchet/read-ratchets)))
linter))
(seq (kondo-ratchet/unjustified #{} occs))))
prod? (needs-exempt? kondo-ratchet/*ratchets-file* prod-occ)
test? (needs-exempt? kondo-ratchet/*test-ratchets-file* test-occ)]
(cond
(and prod? test?) (format "Also add %s to :comment-exempt in both %s and %s -- the inserted ignores have no comments."
linter kondo-ratchet/*ratchets-file* kondo-ratchet/*test-ratchets-file*)
prod? (format "Also add %s to :comment-exempt in %s -- the inserted ignores have no comments."
linter kondo-ratchet/*ratchets-file*)
test? (format "Also add %s to :comment-exempt in %s -- the inserted ignores have no comments."
linter kondo-ratchet/*test-ratchets-file*))))
(defn insert-ignores
"Insert an inline ignore above every site `linter` flags, so a new linter can land without a big-bang
fix. Args: `LINTER [PATHS...]`; paths default to the usual lint roots."
@@ -494,11 +519,6 @@
(count (distinct (map (juxt :filename :row) findings)))
(count by-file)
linter))
;; An existing comment above the flagged form may justify the inserted ignore. Suggest an
;; exemption only when the scanner still finds uncommented ignores; otherwise it is unnecessary
;; and the ratchet check reports it as stale.
(when-not (contains? (:comment-exempt (kondo-ratchet/read-ratchets)) linter)
(when (seq (kondo-ratchet/unjustified #{} (filter #(some #{linter} (:linters %))
(kondo-ratchet/scan roots))))
(println (format "Also add %s to :comment-exempt in %s -- the inserted ignores have no comments."
linter kondo-ratchet/*ratchets-file*))))))))
(some-> (exemption-suggestion linter (filter #(some #{linter} (:linters %))
(kondo-ratchet/scan roots)))
println)))))

View File

@@ -1,12 +1,59 @@
(ns mage.kondo-ratchet-test
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[dev.kondo-ratchet :as dev-ratchet]
[mage.kondo-ratchet :as kondo-ratchet]))
;; Referenced by core_test.clj to ensure namespace is loaded
(def keep-me :loaded)
(defn- with-ratchets-files!
"Run `f` with [[dev.kondo-ratchet/*ratchets-file*]] and [[dev.kondo-ratchet/*test-ratchets-file*]]
bound to temp files carrying `prod-exempt`/`test-exempt` as their only budgets."
[prod-exempt test-exempt f]
(let [dir (.toFile (java.nio.file.Files/createTempDirectory
"kondo-ratchet-test" (make-array java.nio.file.attribute.FileAttribute 0)))
prod (doto (io/file dir "ratchets.edn")
(spit (dev-ratchet/render {:ignore-counts {}, :config-counts {}, :comment-exempt prod-exempt})))
test (doto (io/file dir "ratchets-test.edn")
(spit (dev-ratchet/render-test {:ignore-counts {}, :comment-exempt test-exempt})))]
(binding [dev-ratchet/*ratchets-file* (.getPath prod)
dev-ratchet/*test-ratchets-file* (.getPath test)]
(f))))
(deftest exemption-suggestion-test
(let [prod-occ {:file "src/f.clj", :line 1, :linters [:new], :justified? false}
test-occ {:file "test/g.clj", :line 1, :linters [:new], :justified? false}]
(testing "an unjustified occurrence only in prod code names only the prod file"
(with-ratchets-files! #{} #{}
#(is (= (format "Also add :new to :comment-exempt in %s -- the inserted ignores have no comments."
dev-ratchet/*ratchets-file*)
(#'kondo-ratchet/exemption-suggestion :new [prod-occ])))))
(testing "an unjustified occurrence only in test code names only the test file"
(with-ratchets-files! #{} #{}
#(is (= (format "Also add :new to :comment-exempt in %s -- the inserted ignores have no comments."
dev-ratchet/*test-ratchets-file*)
(#'kondo-ratchet/exemption-suggestion :new [test-occ])))))
(testing "unjustified occurrences in both name both files"
(with-ratchets-files! #{} #{}
#(is (= (format "Also add :new to :comment-exempt in both %s and %s -- the inserted ignores have no comments."
dev-ratchet/*ratchets-file* dev-ratchet/*test-ratchets-file*)
(#'kondo-ratchet/exemption-suggestion :new [prod-occ test-occ])))))
(testing "a file whose own :comment-exempt already covers the linter is not named, even if the other needs it"
(with-ratchets-files! #{:new} #{}
#(is (= (format "Also add :new to :comment-exempt in %s -- the inserted ignores have no comments."
dev-ratchet/*test-ratchets-file*)
(#'kondo-ratchet/exemption-suggestion :new [prod-occ test-occ])))))
(testing "no suggestion once every occurrence is justified"
(with-ratchets-files! #{} #{}
#(is (nil? (#'kondo-ratchet/exemption-suggestion
:new [(assoc prod-occ :justified? true) (assoc test-occ :justified? true)])))))
(testing "no suggestion when there are no occurrences at all"
(with-ratchets-files! #{} #{}
#(is (nil? (#'kondo-ratchet/exemption-suggestion :new [])))))))
(deftest insert-ignore-lines-test
(testing "inserts at the flagged line's indentation"
(is (= "(defn f [x]\n #_{:clj-kondo/ignore [:equals-true]}\n (= true x))\n"

View File

@@ -18,6 +18,9 @@
(def ^:private ratchets-file
".clj-kondo/ratchets.edn")
(def ^:private test-ratchets-file
".clj-kondo/ratchets-test.edn")
(def ^:private module-ratchets-file
".clj-kondo/config/modules/ratchets.edn")
@@ -141,6 +144,30 @@
(is (= #{"M .clj-kondo/ratchets.edn" "UU app.txt"}
(status dir)))))
(deftest resolves-test-ratchets-test
(with-conflict [dir {:base {test-ratchets-file (ratchets {:a 5, :b :unlimited, :ours-drop 2} {} #{:b})}
:ours {test-ratchets-file (ratchets {:a 5, :b 3} {} #{})}
:theirs {test-ratchets-file (ratchets {:a 4, :b :unlimited, :ours-drop 1} {} #{:b})}}]
(let [{:keys [exit out]} (run dir script)]
(is (= 0 exit))
(is (str/includes? out (str "staged merged " test-ratchets-file))))
(let [text (slurp (str (fs/path dir test-ratchets-file)))]
(is (= {:ignore-counts {:a 4, :b 3}
:comment-exempt #{}}
(edn/read-string text))
"merged with the same rules as the prod file, and written without :config-counts")
(is (str/starts-with? text ";; Budgets for kondo suppressions in test code")))
(is (= #{(str "M " test-ratchets-file) "UU app.txt"}
(status dir))))
(testing "a stage with :config-counts is refused rather than having them dropped"
(with-conflict [dir {:base {test-ratchets-file (ratchets {:a 5} {} #{})}
:ours {test-ratchets-file (ratchets {:a 4} {} #{})}
:theirs {test-ratchets-file (ratchets {:a 3} {:c 1} #{})}}]
(let [{:keys [exit out err]} (run dir script)]
(is (pos? exit))
(is (str/includes? (str out err) "must not set :config-counts")))
(is (contains? (status dir) (str "UU " test-ratchets-file))))))
(deftest resolves-module-ratchets-test
(with-conflict [dir {:base {module-ratchets-file "{:api-any 3, :friend-edges 5}\n"}
:ours {module-ratchets-file "{:api-any 2, :friend-edges 5}\n"}
@@ -229,4 +256,4 @@
(with-conflict [dir {:base {ratchets-file base-ratchets}}]
(let [{:keys [exit err]} (run dir script)]
(is (= 1 exit))
(is (str/includes? err "neither ratchet file is conflicted")))))
(is (str/includes? err "no ratchet file is conflicted")))))