diff --git a/.gitignore b/.gitignore
index 534c49538091..3c3cfb328fb0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,6 @@
 # coverage data and reports
 /.coverage
 /htmlcov/
+
+# ruff cache
+/.ruff_cache/
diff --git a/pyproject.toml b/pyproject.toml
index ece2757bb686..656adcd4930a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,7 @@ tests = [
     "pytest >= 7.0",
     "coverage >= 7.0",
     "pytest-cov >= 4.0",
+    "ruff >= 0.5",
 ]
 
 [project.scripts]
@@ -50,3 +51,10 @@ path = "src/wic/cli.py"
 # leftover files under the pytest base temp directory.
 tmp_path_retention_policy = "failed"
 tmp_path_retention_count  = 1
+
+[tool.ruff]
+# For now only the test suite is actually linted (run-tests.sh
+# --lint-tests passes the tests/ path); the wic source under src/ is
+# not yet ruff-clean and is left out until its findings are fixed (see
+# tests/docs/linting.md). --lint-src can still be run to preview the
+# source findings, but it is reported, not enforced.
diff --git a/tests/docs/.gitkeep b/tests/docs/.gitkeep
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/tests/docs/linting.md b/tests/docs/linting.md
new file mode 100644
index 000000000000..71b4de21c100
--- /dev/null
+++ b/tests/docs/linting.md
@@ -0,0 +1,39 @@
+# Linting
+
+## Contents
+
+- [Running the linter](#running-the-linter)
+- [tests/ must be clean](#tests-must-be-clean)
+- [src/ is not linted yet](#src-is-not-linted-yet)
+
+The test suite is linted with [ruff](https://docs.astral.sh/ruff/). It
+is configured in `pyproject.toml` (`[tool.ruff]`).
+
+## Running the linter
+
+The runner exposes ruff through two separate modes, each used on its
+own:
+
+```bash
+tests/run-tests.sh --lint-tests   # ruff over tests/
+tests/run-tests.sh --lint-src     # ruff over src/ (preview only)
+```
+
+A lint mode cannot be combined with coverage, with the other lint
+mode, or with pytest arguments; the runner rejects such combinations.
+
+## tests/ must be clean
+
+Our own test code is held to a clean bar: `tests/run-tests.sh
+--lint-tests` reports nothing. If you add a test that trips a rule, fix
+the test before the change lands.
+
+## src/ is not linted yet
+
+`--lint-src` runs ruff over the wic source, but the source is **not**
+yet ruff-clean, so its findings are a preview report rather than a
+gate: the runner prints them and exits with ruff's status, but nothing
+in the suite asserts on them. Treating `src/` findings as a hard
+failure now would block every run on fixes that have not landed. Once
+the source is cleaned up, `src/` can be promoted to the same clean bar
+as `tests/`.
diff --git a/tests/run-tests.sh b/tests/run-tests.sh
index a483da6a63a6..085dcc93f91d 100755
--- a/tests/run-tests.sh
+++ b/tests/run-tests.sh
@@ -14,23 +14,34 @@ usage() {
     cat <<'USAGE'
 Usage:
   tests/run-tests.sh [--coverage] [--html [DIR]] [pytest args]
+  tests/run-tests.sh --lint-tests
+  tests/run-tests.sh --lint-src
 
 Options:
   --coverage    also measure branch coverage of src/wic and print a
                 terminal report listing the lines that were missed
   --html [DIR]  also write an HTML coverage report (default dir:
                 htmlcov/); implies --coverage
+  --lint-tests  run ruff over tests/ and exit; the test suite is held
+                to a clean bar, so this must report nothing
+  --lint-src    run ruff over src/ and exit; src/ is not yet ruff-clean,
+                so this is a preview report and is not enforced
   -h, --help    show this help and exit
 
 Anything else is passed straight through to pytest (for example a
 path, -k EXPR, or -v). With no such argument the whole suite under
 tests/ is run.
 
+The two lint modes each run on their own; they cannot be combined with
+coverage, with each other, or with pytest arguments.
+
 Examples:
   tests/run-tests.sh                  # whole suite
   tests/run-tests.sh --coverage       # + terminal coverage report
   tests/run-tests.sh --html           # + HTML report in htmlcov/
   tests/run-tests.sh --html /tmp/cov  # + HTML report in /tmp/cov
+  tests/run-tests.sh --lint-tests     # ruff over tests/
+  tests/run-tests.sh --lint-src       # ruff over src/ (preview)
   tests/run-tests.sh -k filemap -v    # pass args through to pytest
   tests/run-tests.sh tests/unit       # a single tier or file
 
@@ -44,6 +55,8 @@ PY="${PYTHON:-python3}"
 coverage=0
 html=0
 html_dir="htmlcov"
+lint_tests=0
+lint_src=0
 pytest_args=()
 while [ $# -gt 0 ]; do
     case "$1" in
@@ -60,6 +73,12 @@ while [ $# -gt 0 ]; do
                 *) html_dir="$2"; shift ;;
             esac
             ;;
+        --lint-tests)
+            lint_tests=1
+            ;;
+        --lint-src)
+            lint_src=1
+            ;;
         -h|--help)
             usage
             exit 0
@@ -78,6 +97,34 @@ done
 
 cd "$REPO_ROOT"
 
+# The lint modes run ruff and exit, so each must be used on its own.
+# Reject combining them with coverage, with each other, or with pytest
+# arguments loudly instead of silently ignoring the extras.
+if [ $((lint_tests + lint_src)) -gt 0 ]; then
+    if [ "$lint_tests" -eq 1 ] && [ "$lint_src" -eq 1 ]; then
+        echo "error: --lint-tests and --lint-src cannot be combined." >&2
+        echo "       run one lint mode at a time." >&2
+        exit 2
+    fi
+    if [ "$coverage" -eq 1 ] || [ "$html" -eq 1 ] || [ ${#pytest_args[@]} -gt 0 ]; then
+        echo "error: a lint mode must be used on its own." >&2
+        echo "       lint, or drop the lint flag to run the suite." >&2
+        exit 2
+    fi
+    if ! "$PY" -c "import ruff" >/dev/null 2>&1 && ! command -v ruff >/dev/null 2>&1; then
+        echo "error: lint requested but ruff is not installed." >&2
+        echo "       run: $PY -m pip install -e \".[tests]\"" >&2
+        exit 1
+    fi
+    if [ "$lint_tests" -eq 1 ]; then
+        # The test suite is held to a clean bar; a finding here is a bug
+        # in our own test code and must be fixed.
+        exec "$PY" -m ruff check tests
+    fi
+    # src/ is not yet ruff-clean; this is a preview report, not a gate.
+    exec "$PY" -m ruff check src
+fi
+
 # Make sure the interpreter that will run pytest can actually import wic.
 # The suites pass even without an install (each adds src/ to sys.path),
 # but the session banner and any install-dependent behaviour would be
