diff --git a/.gitignore b/.gitignore
index e30cafa40f7f..f2fd2f56b6d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
 .pytest_cache/
 .coverage
 htmlcov/
+.ruff_cache/
diff --git a/justfile b/justfile
index 1be3e01e7c7d..5ccd8144181d 100644
--- a/justfile
+++ b/justfile
@@ -16,3 +16,12 @@ coverage *args:
 # Run the suite with an HTML coverage report (written to htmlcov/).
 coverage-html *args:
     pytest {{args}} --cov=wic --cov-branch --cov-report=term-missing --cov-report=html
+
+# Lint the test suite; it is held to a clean bar, so this must be clean.
+lint-tests:
+    ruff check tests
+
+# Lint the test suite and then the wic source. src/ is not yet
+# ruff-clean, so its findings are a preview, not a gate.
+lint: lint-tests
+    ruff check src
diff --git a/pyproject.toml b/pyproject.toml
index 2ad694b13358..569944ff468c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,7 @@ tests = [
     "pytest >= 9.1.1",
     "coverage >= 7.15.0",
     "pytest-cov >= 7.1.0",
+    "ruff == 0.15.20",
     "rust-just >= 1.55.1",
 ]
 
@@ -52,3 +53,10 @@ testpaths = ["tests"]
 # leftover directories under the pytest base temp directory.
 tmp_path_retention_policy = "failed"
 tmp_path_retention_count  = 1
+
+[tool.ruff.lint.per-file-ignores]
+# Test modules prepend the in-tree src/ directory to sys.path before
+# importing wic, so the suite runs against a checkout that has not been
+# installed. That bootstrap runs before the wic imports, which trips
+# E402; the ordering is deliberate, so ignore E402 for the test tree.
+"tests/**" = ["E402"]
