[CI] Add pre-commit check that new tests are tethered to Buildkite jobs (#54867)
Signed-off-by: Turner Jabbour <doubleujabbour@gmail.com> Signed-off-by: Turner <doubleujabbour@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Kevin H. Luu <khluu000@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
Turner Jabbour committed
Sep 21, 2026 at 19:36 UTC
e9f169d16b9408bb9ae44f75072b91a5521d733c
8 files changed
+1920
.buildkite/test_areas/misc.yaml
+4
@@ -525,6 +525,9 @@ steps:
525
- tests/renderers
526
- tests/standalone_tests/lazy_imports.py
527
- tests/reasoning
528
+ - tools/pre_commit/check_test_tethering.py
529
+ - tools/pre_commit/test_tethering_allowlist.txt
530
+ - tests/tools/test_check_test_tethering.py
531
- tests/tool_parsers
532
- tests/tokenizers_
533
- tests/parser
@@ -552,6 +555,7 @@ steps:
555
commands:
556
- pytest -v -s -m 'cpu_test' multimodal
557
- pytest -v -s config
558
+ - pytest -v -s tools/test_check_test_tethering.py
559
560
- label: ":computer: (CPU) Reasoning + Renderers"
561
key: cpu-reasoning-renderers
.pre-commit-config.yaml
+8
@@ -184,6 +184,14 @@ repos:
184
files: ^\.buildkite/.*\.ya?ml$
185
pass_filenames: false
186
additional_dependencies: [PyYAML]
187
+ - id: check-test-tethering
188
+ name: Check new tests are wired into Buildkite CI
189
+ language: python
190
+ entry: python tools/pre_commit/check_test_tethering.py
191
+ files: ^(tests/.*\.py|\.buildkite/(test_areas|hardware_tests|intel_jobs)/.*\.yaml|\.buildkite/(test-amd|ci_config[^/]*)\.yaml|tools/pre_commit/test_tethering_allowlist\.txt)$
192
+ additional_dependencies: [PyYAML, regex]
193
+ verbose: true # full-scan `note:` lines are advisory, so the hook passes
194
+ require_serial: true # one full scan, not one per pre-commit file batch
195
- id: mypy-3.10 # TODO: Use https://github.com/pre-commit/mirrors-mypy when mypy setup is less awkward
196
name: Run mypy for Python 3.10
197
entry: python tools/pre_commit/mypy.py "3.10"
docker/Dockerfile.cpu
+4
@@ -471,6 +471,10 @@ ADD ./benchmarks/ ./benchmarks/
471
ADD ./vllm/collect_env.py .
472
ADD ./docker/ ./docker/
473
ADD ./.buildkite/ ./.buildkite/
474
+# tools/ carries repo-tooling code that a few tests exercise directly
475
+# (e.g. tests/tools/test_check_test_tethering.py imports
476
+# tools.pre_commit.check_test_tethering).
477
+ADD ./tools/ ./tools/
478
479
# install development dependencies (for testing)
480
RUN --mount=type=cache,target=/root/.cache/uv \
tests/tools/test_check_test_tethering.py
new
+879
@@ -0,0 +1,879 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Unit tests for the test-tethering pre-commit check.
4
+
5
+The check answers one question - "does any Buildkite job (in
6
+``.buildkite/test_areas/`` or the legacy ``.buildkite/test-amd.yaml``) collect
7
+this test file?" - by parsing job ``commands`` as shell. The risk that
8
+matters is a *false tether*: a misparse that invents coverage makes the gate
9
+pass while the test still never runs, which is the exact failure the check
10
+exists to catch. So the parser cases below pin both directions, and
11
+:func:`test_no_selection_matches_everything` guards the catastrophic version of
12
+it.
13
+"""
14
+
15
+import textwrap
16
+from pathlib import Path
17
+
18
+import pytest
19
+
20
+import tools.pre_commit.check_test_tethering as checker
21
+from tools.pre_commit.check_test_tethering import (
22
+ FindSelection,
23
+ PytestSelection,
24
+ _parse_command,
25
+ _to_repo_relative,
26
+ all_test_modules,
27
+ allowlist_entries_missing_reason,
28
+ is_test_module,
29
+ is_tethered,
30
+ load_allowlist,
31
+ load_selections,
32
+ main,
33
+ normalize_test_path,
34
+ run_changed_files_check,
35
+ run_full_scan,
36
+)
37
+
38
+REPO_ROOT = Path(__file__).resolve().parents[2]
39
+
40
+
41
+def runs(command: str, test_file: str) -> bool:
42
+ """True if `command` collects `test_file` (path relative to ``tests/``)."""
43
+ return any(sel.runs(test_file) for sel in _parse_command(command))
44
+
45
+
46
+# --------------------------------------------------------------------------- #
47
+# pytest path arguments
48
+# --------------------------------------------------------------------------- #
49
+
50
+
51
+@pytest.mark.parametrize(
52
+ ("command", "test_file", "expected"),
53
+ [
54
+ # Directory sweeps cover everything nested underneath.
55
+ ("pytest -v -s kernels", "kernels/test_attention.py", True),
56
+ ("pytest -v -s kernels/", "kernels/moe/test_moe.py", True),
57
+ ("pytest -v -s kernels", "lora/test_lora.py", False),
58
+ # An exact file argument covers that file and nothing beside it.
59
+ ("pytest -v -s kernels/test_a.py", "kernels/test_a.py", True),
60
+ ("pytest -v -s kernels/test_a.py", "kernels/test_b.py", False),
61
+ # Commands run from the repo root and from tests/ alike.
62
+ ("pytest -v -s tests/kernels/test_a.py", "kernels/test_a.py", True),
63
+ ("pytest -v -s ./kernels/test_a.py", "kernels/test_a.py", True),
64
+ (
65
+ "pytest -v -s /vllm-workspace/tests/kernels/test_a.py",
66
+ "kernels/test_a.py",
67
+ True,
68
+ ),
69
+ # A ::nodeid selector still collects the file.
70
+ ("pytest -v -s kernels/test_a.py::test_one", "kernels/test_a.py", True),
71
+ ("pytest -v -s kernels/test_a.py::Cls::test_one", "kernels/test_a.py", True),
72
+ # Several positional paths, including a bare root-level file.
73
+ ("pytest -v -s test_envs.py test_outputs.py", "test_outputs.py", True),
74
+ ("pytest -v -s test_envs.py test_outputs.py", "test_regression.py", False),
75
+ # A bare word is a path only when it is really a directory under tests/.
76
+ ("pytest -v -s samplers", "samplers/test_sampler.py", True),
77
+ # Glob path args do not cross a directory separator, matching the shell.
78
+ ("pytest -v -s kernels/test_*.py", "kernels/test_a.py", True),
79
+ ("pytest -v -s kernels/test_*.py", "kernels/moe/test_a.py", False),
80
+ ("pytest -v -s kernels/test_*.py", "kernels/helper.py", False),
81
+ ],
82
+)
83
+def test_pytest_path_arguments(command, test_file, expected):
84
+ assert runs(command, test_file) is expected
85
+
86
+
87
+# --------------------------------------------------------------------------- #
88
+# --ignore / --deselect
89
+# --------------------------------------------------------------------------- #
90
+
91
+
92
+@pytest.mark.parametrize(
93
+ ("command", "test_file", "expected"),
94
+ [
95
+ # --ignore removes a file from an otherwise covering sweep, in both the
96
+ # `=` and space-separated spellings.
97
+ ("pytest -v -s kernels --ignore=kernels/test_a.py", "kernels/test_a.py", False),
98
+ ("pytest -v -s kernels --ignore kernels/test_a.py", "kernels/test_a.py", False),
99
+ # ...and only that file.
100
+ ("pytest -v -s kernels --ignore=kernels/test_a.py", "kernels/test_b.py", True),
101
+ # --ignore of a directory removes the whole subtree.
102
+ ("pytest -v -s kernels --ignore=kernels/moe", "kernels/moe/test_moe.py", False),
103
+ (
104
+ "pytest -v -s lora --ignore-glob=lora/*_tp.py",
105
+ "lora/test_llama_tp.py",
106
+ False,
107
+ ),
108
+ # --ignore-glob is a whole-path fnmatch (pytest semantics): its `*`
109
+ # crosses `/`, so a bare `*_tp.py` drops a nested match too. A positional
110
+ # path glob would not - that is the distinction the parser has to keep.
111
+ ("pytest -v -s kernels --ignore-glob=*_tp.py", "kernels/test_a_tp.py", False),
112
+ (
113
+ "pytest -v -s kernels --ignore-glob=*_tp.py",
114
+ "kernels/nested/test_a_tp.py",
115
+ False,
116
+ ),
117
+ ("pytest -v -s kernels --ignore-glob '*_tp.py'", "kernels/test_a.py", True),
118
+ # A node-id deselect leaves the file collected; a bare-path one does not.
119
+ (
120
+ "pytest -v -s kernels --deselect kernels/test_a.py::test_one",
121
+ "kernels/test_a.py",
122
+ True,
123
+ ),
124
+ (
125
+ "pytest -v -s kernels --deselect kernels/test_a.py",
126
+ "kernels/test_a.py",
127
+ False,
128
+ ),
129
+ ],
130
+)
131
+def test_ignore_and_deselect(command, test_file, expected):
132
+ assert runs(command, test_file) is expected
133
+
134
+
135
+# --------------------------------------------------------------------------- #
136
+# Options, env prefixes and other token noise
137
+# --------------------------------------------------------------------------- #
138
+
139
+
140
+@pytest.mark.parametrize(
141
+ ("command", "test_file", "expected"),
142
+ [
143
+ # A -k / -m filter narrows which tests run but does not un-run the file.
144
+ ("pytest -v -s kernels -k 'not slow'", "kernels/test_a.py", True),
145
+ ("pytest -v -s kernels -m core_model", "kernels/test_a.py", True),
146
+ # A value-taking option's value is never mistaken for a path.
147
+ ("pytest -m core_model", "core_model", False),
148
+ ("pytest -k kernels", "kernels/test_a.py", False),
149
+ ("pytest -n 4 kernels/test_a.py", "kernels/test_a.py", True),
150
+ # Buildkite shard flags carry a $$VAR and must not swallow the path.
151
+ (
152
+ (
153
+ "pytest -v -s kernels --shard-id=$$BUILDKITE_PARALLEL_JOB "
154
+ "--num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT"
155
+ ),
156
+ "kernels/test_a.py",
157
+ True,
158
+ ),
159
+ # A leading env-var assignment is not a path.
160
+ (
161
+ "VLLM_TEST_FORCE_LOAD_FORMAT=auto pytest -v -s kernels",
162
+ "kernels/test_a.py",
163
+ True,
164
+ ),
165
+ # `python -m pytest` is the same invocation.
166
+ ("python3 -m pytest -v -s kernels/test_a.py", "kernels/test_a.py", True),
167
+ # Quoted paths, wrapper commands and redirects all survive tokenizing.
168
+ ("pytest -v -s 'kernels/test_a.py'", "kernels/test_a.py", True),
169
+ ("timeout 600 pytest kernels/test_a.py", "kernels/test_a.py", True),
170
+ ("pytest kernels/test_a.py > out.log 2>&1", "kernels/test_a.py", True),
171
+ # Each sub-command of a compound line is classified on its own.
172
+ ("pytest kernels/test_a.py && pytest lora/test_b.py", "lora/test_b.py", True),
173
+ ("pytest kernels/test_a.py ; pytest lora/test_b.py", "lora/test_b.py", True),
174
+ # An unresolvable $VAR path yields no coverage rather than a guess.
175
+ ("pytest -v -s $TEST_TARGET", "kernels/test_a.py", False),
176
+ ],
177
+)
178
+def test_option_and_token_handling(command, test_file, expected):
179
+ assert runs(command, test_file) is expected
180
+
181
+
182
+# --------------------------------------------------------------------------- #
183
+# find | xargs pytest pipelines
184
+# --------------------------------------------------------------------------- #
185
+
186
+
187
+@pytest.mark.parametrize(
188
+ ("command", "test_file", "expected"),
189
+ [
190
+ ("find kernels -name 'test_*.py' | xargs pytest -v", "kernels/test_a.py", True),
191
+ (
192
+ "find kernels -name 'test_*.py' | xargs pytest -v",
193
+ "kernels/moe/test_b.py",
194
+ True,
195
+ ),
196
+ # A non-matching filename is not collected by the find expression.
197
+ (
198
+ "find kernels -name 'test_*.py' | xargs pytest -v",
199
+ "kernels/helper.py",
200
+ False,
201
+ ),
202
+ # -maxdepth 1 keeps direct children and drops nested ones.
203
+ (
204
+ "find kernels -maxdepth 1 -name 'test_*.py' | xargs pytest",
205
+ "kernels/test_a.py",
206
+ True,
207
+ ),
208
+ (
209
+ "find kernels -maxdepth 1 -name 'test_*.py' | xargs pytest",
210
+ "kernels/moe/test_b.py",
211
+ False,
212
+ ),
213
+ # -not -name excludes its target and leaves siblings alone.
214
+ (
215
+ "find kernels -name 'test_*.py' -not -name 'test_skip.py' | xargs pytest",
216
+ "kernels/test_skip.py",
217
+ False,
218
+ ),
219
+ (
220
+ "find kernels -name 'test_*.py' -not -name 'test_skip.py' | xargs pytest",
221
+ "kernels/test_a.py",
222
+ True,
223
+ ),
224
+ # The root confines the sweep.
225
+ ("find kernels -name 'test_*.py' | xargs pytest", "lora/test_a.py", False),
226
+ # -maxdepth is honored even when the find root is the tests/ root itself
227
+ # ("." or "tests/"), not just a subdir.
228
+ (
229
+ "find . -maxdepth 1 -name 'test_*.py' | xargs pytest",
230
+ "test_regression.py",
231
+ True,
232
+ ),
233
+ (
234
+ "find . -maxdepth 1 -name 'test_*.py' | xargs pytest",
235
+ "v1/test_scheduler.py",
236
+ False,
237
+ ),
238
+ (
239
+ "find tests/ -maxdepth 1 -name 'test_*.py' | xargs pytest",
240
+ "v1/test_scheduler.py",
241
+ False,
242
+ ),
243
+ # -exec pytest wires the sweep just as `| xargs pytest` does.
244
+ (
245
+ "find compile -name 'test_*.py' -exec pytest -s -v {} \\;",
246
+ "compile/test_a.py",
247
+ True,
248
+ ),
249
+ # A pytest in a *separate* command on the line (not fed by the find) does
250
+ # not arm the sweep - the find here is piped to `wc`.
251
+ (
252
+ "find kernels -name 'test_*.py' | wc -l ; pytest test_regression.py",
253
+ "kernels/test_a.py",
254
+ False,
255
+ ),
256
+ (
257
+ "find kernels -name 'test_*.py' -delete && pytest test_regression.py",
258
+ "kernels/test_a.py",
259
+ False,
260
+ ),
261
+ # ...but only a *non-filtering* intermediate stage. `sort` reorders,
262
+ # every match still reaches pytest.
263
+ (
264
+ "find kernels -name 'test_*.py' | sort | xargs pytest",
265
+ "kernels/test_a.py",
266
+ True,
267
+ ),
268
+ # `head` drops all but one match - the sweep is not fully tethered.
269
+ (
270
+ "find kernels -name 'test_*.py' | head -n 1 | xargs pytest",
271
+ "kernels/test_a.py",
272
+ False,
273
+ ),
274
+ (
275
+ "find kernels -name 'test_*.py' | grep attention | xargs pytest",
276
+ "kernels/test_a.py",
277
+ False,
278
+ ),
279
+ # `xargs` runs `echo` here, not pytest - the trailing `pytest` is echo's
280
+ # argument.
281
+ (
282
+ "find kernels -name 'test_*.py' | xargs echo pytest",
283
+ "kernels/test_a.py",
284
+ False,
285
+ ),
286
+ # An `-exec` with no `\;` / `+` terminator is a `find` syntax error - it
287
+ # never runs pytest.
288
+ (
289
+ "find kernels -name 'test_*.py' -exec pytest {}",
290
+ "kernels/test_a.py",
291
+ False,
292
+ ),
293
+ # An `-exec pytest` with no `{}` runs only the named file for every
294
+ # match - the matched files never reach pytest.
295
+ (
296
+ "find kernels -name 'test_*.py' -exec pytest test_regression.py \\;",
297
+ "kernels/test_a.py",
298
+ False,
299
+ ),
300
+ # `{}` as an option *value* (`--ignore {}`) doesn't reach pytest as a
301
+ # test path either - only `test_regression.py` runs.
302
+ (
303
+ (
304
+ "find kernels -name 'test_*.py' -exec pytest test_regression.py "
305
+ "--ignore {} \\;"
306
+ ),
307
+ "kernels/test_a.py",
308
+ False,
309
+ ),
310
+ # xargs value-flags with a space don't swallow the command.
311
+ (
312
+ "find kernels -name 'test_*.py' | xargs -0 -I {} pytest {}",
313
+ "kernels/test_a.py",
314
+ True,
315
+ ),
316
+ # `xargs env FOO=1 pytest` - xargs runs `env`, which runs pytest.
317
+ (
318
+ "find kernels -name 'test_*.py' | xargs env FOO=1 pytest",
319
+ "kernels/test_a.py",
320
+ True,
321
+ ),
322
+ ],
323
+)
324
+def test_find_pipelines(command, test_file, expected):
325
+ assert runs(command, test_file) is expected
326
+
327
+
328
+def test_find_pipeline_parses_as_find_selection():
329
+ (selection,) = [
330
+ s
331
+ for s in _parse_command("find kernels -name 'test_*.py' | xargs pytest -v")
332
+ if isinstance(s, FindSelection)
333
+ ]
334
+ assert selection.root == "kernels"
335
+ assert selection.name_globs == ["test_*.py"]
336
+
337
+
338
+# --------------------------------------------------------------------------- #
339
+# Direct file runners
340
+# --------------------------------------------------------------------------- #
341
+
342
+
343
+@pytest.mark.parametrize(
344
+ ("command", "test_file", "expected"),
345
+ [
346
+ (
347
+ "python3 standalone_tests/lazy_imports.py",
348
+ "standalone_tests/lazy_imports.py",
349
+ True,
350
+ ),
351
+ (
352
+ "python standalone_tests/lazy_imports.py",
353
+ "standalone_tests/lazy_imports.py",
354
+ True,
355
+ ),
356
+ # `timeout -k <grace> <dur>` wrapping a runner: the grace value must not
357
+ # be consumed as the duration (which would hide `python`).
358
+ (
359
+ "timeout -k 5s 30s python standalone_tests/lazy_imports.py",
360
+ "standalone_tests/lazy_imports.py",
361
+ True,
362
+ ),
363
+ (
364
+ "torchrun --nproc-per-node 2 distributed/test_a.py",
365
+ "distributed/test_a.py",
366
+ True,
367
+ ),
368
+ # The option's value is a count, not a path.
369
+ ("torchrun --nproc-per-node 2 distributed/test_a.py", "2", False),
370
+ (
371
+ "VLLM_X=1 python3 standalone_tests/lazy_imports.py | grep -q ok",
372
+ "standalone_tests/lazy_imports.py",
373
+ True,
374
+ ),
375
+ # Only the executed script counts; the tokens after it are that script's
376
+ # own argv. `--suffix v1` must not tether the whole tests/v1 subtree.
377
+ (
378
+ (
379
+ "python3 features/tensorize_vllm_model.py --model facebook/opt-125m "
380
+ "serialize --serialized-directory /tmp/ --suffix v1"
381
+ ),
382
+ "v1/test_scheduler.py",
383
+ False,
384
+ ),
385
+ (
386
+ (
387
+ "python3 features/tensorize_vllm_model.py --model facebook/opt-125m "
388
+ "serialize --serialized-directory /tmp/ --suffix v1"
389
+ ),
390
+ "features/tensorize_vllm_model.py",
391
+ True,
392
+ ),
393
+ # A script named by an absolute `$VAR` path still resolves.
394
+ (
395
+ 'python3 -m pytest -s -x "${GIT_ROOT}/tests/v1/e2e/test_x.py"',
396
+ "v1/e2e/test_x.py",
397
+ True,
398
+ ),
399
+ # `python -m <module>` runs the module, not a script - only `-m pytest`
400
+ # collects tests. `compileall kernels` byte-compiles, it does not run.
401
+ ("python -m compileall kernels", "kernels/test_a.py", False),
402
+ ("python -m mypy kernels/test_a.py", "kernels/test_a.py", False),
403
+ # `coverage run -m pytest` still resolves through to pytest.
404
+ ("coverage run -m pytest kernels/test_a.py", "kernels/test_a.py", True),
405
+ # `python -c '<code>' file.py` runs the code string; `file.py` is argv.
406
+ ("python -c 'pass' kernels/test_a.py", "kernels/test_a.py", False),
407
+ ("python -c 'import sys' kernels/test_a.py", "kernels/test_a.py", False),
408
+ # a `-m pytest` *after* the `-c` code operand is that program's argv too.
409
+ ("python -c 'pass' -m pytest kernels/test_a.py", "kernels/test_a.py", False),
410
+ # ...but `-c` as the *script's* own arg still tethers the script.
411
+ ("python kernels/test_a.py -c config.py", "kernels/test_a.py", True),
412
+ # ...and pytest's own `-c <cfg>` (after `-m pytest`) is fine.
413
+ ("python -m pytest kernels/test_a.py -c setup.cfg", "kernels/test_a.py", True),
414
+ ],
415
+)
416
+def test_direct_runners(command, test_file, expected):
417
+ assert runs(command, test_file) is expected
418
+
419
+
420
+# --------------------------------------------------------------------------- #
421
+# Commands that must contribute no coverage
422
+# --------------------------------------------------------------------------- #
423
+
424
+
425
+@pytest.mark.parametrize(
426
+ "command",
427
+ [
428
+ "export VLLM_WORKER_MULTIPROC_METHOD=spawn",
429
+ "pip install pytest-timeout pytest-forked",
430
+ "uv pip install -r requirements/test/cuda.in",
431
+ "bash tests/does_not_exist.sh",
432
+ "echo 'pytest kernels/test_a.py'",
433
+ # `pytest` as an argument to another command - `echo` runs, not pytest.
434
+ "echo pytest kernels/test_a.py",
435
+ 'pytest -v -s "unbalanced',
436
+ # A `.sh` that is only an *argument* (Buildkite printing / copying a
437
+ # filename) is not a script invocation.
438
+ "echo tests/foo.sh",
439
+ "cp scripts/run_tests.sh /tmp/",
440
+ # A `find` whose output never reaches pytest is not a test selection.
441
+ "find kernels -name 'test_*.py'",
442
+ "find kernels -name 'test_*.py' | wc -l",
443
+ "find kernels -name 'test_*.py' -delete",
444
+ "find kernels -name 'test_*.py' | xargs echo pytest",
445
+ "find kernels -name 'test_*.py' -exec pytest {}",
446
+ ],
447
+)
448
+def test_non_test_commands_contribute_nothing(command):
449
+ assert _parse_command(command) == [] or not any(
450
+ sel.runs("kernels/test_a.py") for sel in _parse_command(command)
451
+ )
452
+
453
+
454
+# --------------------------------------------------------------------------- #
455
+# Job-level yaml parsing
456
+# --------------------------------------------------------------------------- #
457
+
458
+
459
+def _write_area(tmp_path, monkeypatch, body: str):
460
+ mod = checker
461
+ area_dir = tmp_path / "test_areas"
462
+ area_dir.mkdir(exist_ok=True)
463
+ (area_dir / "synthetic.yaml").write_text(textwrap.dedent(body))
464
+ monkeypatch.setattr(mod, "TEST_AREAS_DIR", area_dir)
465
+ # Keep the real test-amd.yaml out of these isolated parsing tests.
466
+ monkeypatch.setattr(mod, "TEST_AMD_YAML", tmp_path / "no-such-test-amd.yaml")
467
+ # The yaml-error path reports a repo-relative name, so REPO_ROOT has to
468
+ # contain the synthetic area too.
469
+ monkeypatch.setattr(mod, "REPO_ROOT", tmp_path)
470
+ return load_selections()
471
+
472
+
473
+def test_plain_step_commands_are_collected(tmp_path, monkeypatch):
474
+ selections = _write_area(
475
+ tmp_path,
476
+ monkeypatch,
477
+ """
478
+ steps:
479
+ - label: ":nvidia: (H200) Kernels"
480
+ commands:
481
+ - pytest -v -s kernels
482
+ """,
483
+ )
484
+ assert is_tethered("kernels/test_a.py", selections)
485
+
486
+
487
+def test_singular_command_key_is_collected(tmp_path, monkeypatch):
488
+ selections = _write_area(
489
+ tmp_path,
490
+ monkeypatch,
491
+ """
492
+ steps:
493
+ - label: "Singular"
494
+ command: pytest -v -s kernels/test_a.py
495
+ """,
496
+ )
497
+ assert is_tethered("kernels/test_a.py", selections)
498
+
499
+
500
+def test_test_amd_yaml_coverage_counts_as_tethered(tmp_path, monkeypatch):
501
+ """A test wired only into the legacy test-amd.yaml is still run by CI."""
502
+ mod = checker
503
+ (tmp_path / "test_areas").mkdir()
504
+ amd_yaml = tmp_path / "test-amd.yaml"
505
+ amd_yaml.write_text(
506
+ textwrap.dedent(
507
+ """
508
+ steps:
509
+ - label: ":amd: (MI300) Quantization"
510
+ commands:
511
+ - VLLM_TEST_FORCE_LOAD_FORMAT=auto pytest -v -s quantization/
512
+ - pytest -v -s rocm/test_moe_weight_replay.py
513
+ """
514
+ )
515
+ )
516
+ monkeypatch.setattr(mod, "TEST_AREAS_DIR", tmp_path / "test_areas")
517
+ monkeypatch.setattr(mod, "TEST_AMD_YAML", amd_yaml)
518
+ monkeypatch.setattr(mod, "REPO_ROOT", tmp_path)
519
+ selections = load_selections()
520
+ assert is_tethered("rocm/test_moe_weight_replay.py", selections)
521
+ assert is_tethered("quantization/test_foo.py", selections)
522
+
523
+
524
+def test_missing_test_amd_yaml_is_not_fatal(tmp_path, monkeypatch):
525
+ """When the migration finally deletes test-amd.yaml, the checker still runs."""
526
+ mod = checker
527
+ (tmp_path / "test_areas").mkdir()
528
+ monkeypatch.setattr(mod, "TEST_AREAS_DIR", tmp_path / "test_areas")
529
+ monkeypatch.setattr(mod, "TEST_AMD_YAML", tmp_path / "gone.yaml")
530
+ monkeypatch.setattr(mod, "REPO_ROOT", tmp_path)
531
+ assert load_selections() == []
532
+
533
+
534
+def test_job_dirs_come_from_ci_config(tmp_path, monkeypatch):
535
+ """The pipeline directories are whatever `.buildkite/ci_config*.yaml` lists
536
+ under `job_dirs:` - so a test wired only into e.g. `hardware_tests/` counts,
537
+ and an unreadable config falls back to `test_areas/` alone."""
538
+ mod = checker
539
+ bk = tmp_path / ".buildkite"
540
+ (bk / "test_areas").mkdir(parents=True)
541
+ (bk / "hardware_tests").mkdir()
542
+ (bk / "ci_config.yaml").write_text("job_dirs:\n - .buildkite/hardware_tests\n")
543
+ (bk / "ci_config_broken.yaml").write_text("job_dirs: [oops\n") # ignored
544
+ (bk / "hardware_tests" / "cpu.yaml").write_text(
545
+ textwrap.dedent(
546
+ """
547
+ steps:
548
+ - label: "CPU"
549
+ commands:
550
+ - pytest -v -s v1/e2e/test_cpu_thing.py
551
+ """
552
+ )
553
+ )
554
+ monkeypatch.setattr(mod, "REPO_ROOT", tmp_path)
555
+ monkeypatch.setattr(mod, "TEST_AREAS_DIR", bk / "test_areas")
556
+ monkeypatch.setattr(mod, "TEST_AMD_YAML", tmp_path / "no-amd.yaml")
557
+
558
+ assert (bk / "hardware_tests") in checker._pipeline_job_dirs()
559
+ assert is_tethered("v1/e2e/test_cpu_thing.py", load_selections())
560
+
561
+
562
+def test_step_level_ignore_is_honored(tmp_path, monkeypatch):
563
+ selections = _write_area(
564
+ tmp_path,
565
+ monkeypatch,
566
+ """
567
+ steps:
568
+ - label: "Kernels"
569
+ commands:
570
+ - pytest -v -s kernels --ignore=kernels/test_a.py
571
+ """,
572
+ )
573
+ assert not is_tethered("kernels/test_a.py", selections)
574
+ assert is_tethered("kernels/test_b.py", selections)
575
+
576
+
577
+def test_amd_mirror_only_command_is_collected(tmp_path, monkeypatch):
578
+ """A mirror that overrides `commands` runs tests the base step never does."""
579
+ selections = _write_area(
580
+ tmp_path,
581
+ monkeypatch,
582
+ """
583
+ steps:
584
+ - label: ":nvidia: (H200) Base"
585
+ commands:
586
+ - pytest -v -s kernels/test_base.py
587
+ mirror:
588
+ amd:
589
+ label: ":amd: (MI300) Base"
590
+ commands:
591
+ - pytest -v -s kernels/test_amd_only.py
592
+ """,
593
+ )
594
+ assert is_tethered("kernels/test_base.py", selections)
595
+ assert is_tethered("kernels/test_amd_only.py", selections)
596
+
597
+
598
+def test_shell_script_continuation_vars_and_nesting(tmp_path, monkeypatch):
599
+ """The v1 P/D integration scripts name their test through a `\\`-continued
600
+ line, a literal shell var, and a nested `bash "$SCRIPT"`. Each indirection
601
+ has to be followed or the check falsely reports the test untethered."""
602
+ scripts = tmp_path / "tests" / "v1" / "kv_connector" / "nixl_integration"
603
+ scripts.mkdir(parents=True)
604
+ (scripts / "inner.sh").write_text(
605
+ "python3 -m pytest -s -v \\\n"
606
+ ' "${GIT_ROOT}/tests/v1/kv_connector/nixl_integration/test_inner.py"\n'
607
+ )
608
+ (scripts / "sweep.sh").write_text(
609
+ 'SCRIPT="v1/kv_connector/nixl_integration/inner.sh"\n'
610
+ 'IMPORT_CANARY="v1/kv_connector/nixl_integration/test_canary.py"\n'
611
+ 'LITERAL="v1/kv_connector/nixl_integration/test_not_run.py"\n'
612
+ 'python3 -m pytest -s -x "${IMPORT_CANARY}"\n'
613
+ # single-quoted: the shell passes a literal $LITERAL, collecting nothing
614
+ "python3 -m pytest -s -x '${LITERAL}'\n"
615
+ 'if ! env FOO=1 bash "${SCRIPT}"; then exit 1; fi\n'
616
+ )
617
+ monkeypatch.setattr(checker, "REPO_ROOT", tmp_path)
618
+ monkeypatch.setattr(checker, "TESTS_DIR", tmp_path / "tests")
619
+
620
+ selections = _parse_command("bash v1/kv_connector/nixl_integration/sweep.sh")
621
+
622
+ def tethered(path):
623
+ return any(s.runs(path) for s in selections)
624
+
625
+ # `\`-continuation + $VAR-prefixed absolute path, resolved through a nested
626
+ # `env FOO=1 bash "$SCRIPT"`:
627
+ assert tethered("v1/kv_connector/nixl_integration/test_inner.py")
628
+ # literal `VAR=path` + double-quoted `"$VAR"` (the import-canary pattern):
629
+ assert tethered("v1/kv_connector/nixl_integration/test_canary.py")
630
+ # single-quoted `'$VAR'` is NOT expanded by the shell, so not tethered:
631
+ assert not tethered("v1/kv_connector/nixl_integration/test_not_run.py")
632
+
633
+
634
+def test_bash_dash_c_does_not_follow_a_script_operand(tmp_path, monkeypatch):
635
+ """`bash -c '<code>' foo.sh` runs the `-c` string; `foo.sh` is `$0` and is
636
+ never executed, so its contents must not count as coverage."""
637
+ (tmp_path / "tests").mkdir()
638
+ (tmp_path / "tests" / "decoy.sh").write_text("pytest -v -s lora/test_decoy.py\n")
639
+ monkeypatch.setattr(checker, "REPO_ROOT", tmp_path)
640
+ monkeypatch.setattr(checker, "TESTS_DIR", tmp_path / "tests")
641
+
642
+ with_c = _parse_command("bash -c 'echo hi' tests/decoy.sh")
643
+ assert not any(s.runs("lora/test_decoy.py") for s in with_c)
644
+ # ...but a plain `bash tests/decoy.sh` still follows it.
645
+ plain = _parse_command("bash tests/decoy.sh")
646
+ assert any(s.runs("lora/test_decoy.py") for s in plain)
647
+ # ...and a `-c` that is the *script's* own flag (not bash's) is not `bash -c`.
648
+ script_flag = _parse_command("bash tests/decoy.sh -c models.txt")
649
+ assert any(s.runs("lora/test_decoy.py") for s in script_flag)
650
+ # A `bash -c` trailing arg that looks like a command string is `$0`, not run.
651
+ cmd_string_arg = _parse_command("bash -c 'echo ok' 'pytest lora/test_x.py'")
652
+ assert not any(s.runs("lora/test_x.py") for s in cmd_string_arg)
653
+ # ...but the `-c` code string *itself* is parsed - it is what runs.
654
+ code_runs = _parse_command("bash -c 'pytest lora/test_x.py'")
655
+ assert any(s.runs("lora/test_x.py") for s in code_runs)
656
+ # a combined short-option group resolves the `-c` operand with `c` in any
657
+ # position (`-ec`, `-ce`), and its trailing args are still `$0`, not run.
658
+ for grp in ("-ec", "-ce", "-euxc"):
659
+ assert any(
660
+ s.runs("lora/test_x.py")
661
+ for s in _parse_command(f"bash {grp} 'pytest lora/test_x.py'")
662
+ )
663
+ assert not any(
664
+ s.runs("lora/test_x.py")
665
+ for s in _parse_command("bash -ce 'echo ok' 'pytest lora/test_x.py'")
666
+ )
667
+
668
+
669
+def test_unparsable_yaml_is_fatal(tmp_path, monkeypatch):
670
+ """Silently skipping a bad yaml would drop its coverage and report false
671
+ untethered files, so it must fail loudly instead."""
672
+ with pytest.raises(SystemExit):
673
+ _write_area(tmp_path, monkeypatch, "steps: [oops\n")
674
+
675
+
676
+def test_non_mapping_yaml_is_fatal(tmp_path, monkeypatch):
677
+ """A yaml that parses but isn't a pipeline mapping (a bare list or scalar)
678
+ must raise the actionable error, not crash on ``.get``."""
679
+ with pytest.raises(SystemExit):
680
+ _write_area(tmp_path, monkeypatch, "- just\n- a\n- list\n")
681
+
682
+
683
+# --------------------------------------------------------------------------- #
684
+# is_test_module / path normalization
685
+# --------------------------------------------------------------------------- #
686
+
687
+
688
+@pytest.mark.parametrize(
689
+ ("path", "expected"),
690
+ [
691
+ ("tests/kernels/test_a.py", True),
692
+ ("tests/kernels/a_test.py", True),
693
+ ("tests/kernels/conftest.py", False),
694
+ ("tests/kernels/__init__.py", False),
695
+ ("tests/kernels/utils.py", False),
696
+ ("tests/kernels/test_a.txt", False),
697
+ ("vllm/kernels/test_a.py", False),
698
+ ],
699
+)
700
+def test_is_test_module(path, expected):
701
+ assert is_test_module(path) is expected
702
+
703
+
704
+@pytest.mark.parametrize(
705
+ ("raw", "expected"),
706
+ [
707
+ ("tests/kernels/test_a.py", "kernels/test_a.py"),
708
+ ("./kernels/test_a.py", "kernels/test_a.py"),
709
+ ("/vllm-workspace/tests/kernels/test_a.py", "kernels/test_a.py"),
710
+ ("'kernels/test_a.py'", "kernels/test_a.py"),
711
+ ("kernels/test_a.py::test_one", "kernels/test_a.py"),
712
+ ("kernels/", "kernels"),
713
+ ("${GIT_ROOT}/tests/kernels/test_a.py", "kernels/test_a.py"),
714
+ ("$GIT_ROOT/tests/kernels/test_a.py", "kernels/test_a.py"),
715
+ ],
716
+)
717
+def test_normalize_test_path(raw, expected):
718
+ assert normalize_test_path(raw) == expected
719
+
720
+
721
+# --------------------------------------------------------------------------- #
722
+# Whole-tree invariants
723
+# --------------------------------------------------------------------------- #
724
+
725
+
726
+def test_real_tree_parses_into_selections():
727
+ assert len(load_selections()) > 200
728
+ assert len(all_test_modules()) > 1000
729
+
730
+
731
+def test_no_selection_matches_everything():
732
+ """A path arg that normalizes to '' or '.' would tether every test in the
733
+ repo and silently disarm the gate - the worst possible misparse."""
734
+ for selection in load_selections():
735
+ if isinstance(selection, PytestSelection):
736
+ offenders = [
737
+ p
738
+ for p in selection.included_paths
739
+ if normalize_test_path(p) in ("", ".")
740
+ ]
741
+ assert not offenders, f"match-everything pytest arg: {offenders}"
742
+ else:
743
+ assert normalize_test_path(selection.root) not in ("", ".")
744
+
745
+
746
+def test_tethering_is_deterministic():
747
+ selections = load_selections()
748
+ modules = all_test_modules()[:200]
749
+ first = [is_tethered(m, selections) for m in modules]
750
+ assert first == [is_tethered(m, load_selections()) for m in modules]
751
+
752
+
753
+def test_allowlist_entries_are_well_formed():
754
+ allowlist = load_allowlist()
755
+ assert allowlist, "allowlist should not be empty while gaps remain"
756
+ assert all(is_test_module(path) for path in allowlist)
757
+ assert all(not path.startswith("/") for path in allowlist)
758
+
759
+
760
+def test_real_allowlist_entries_all_have_a_reason():
761
+ assert allowlist_entries_missing_reason() == []
762
+
763
+
764
+def test_bare_allowlist_entry_is_flagged(tmp_path, monkeypatch):
765
+ allowlist = tmp_path / "allowlist.txt"
766
+ allowlist.write_text(
767
+ "tests/a/test_ok.py # torchrun multi-GPU, no job\n"
768
+ "tests/b/test_bare.py\n"
769
+ " # a standalone comment line is fine\n"
770
+ )
771
+ monkeypatch.setattr(checker, "ALLOWLIST_PATH", allowlist)
772
+ assert allowlist_entries_missing_reason() == ["tests/b/test_bare.py"]
773
+
774
+
775
+def test_real_tree_has_no_unallowlisted_gaps():
776
+ """Every test module is either collected by a job or allowlisted.
777
+
778
+ Deliberately non-strict: a stale allowlist entry is advisory by design, so
779
+ asserting `strict=True` here would make unrelated allowlist cleanup block
780
+ CI - the exact behaviour the two-mode design avoids.
781
+ """
782
+ assert run_full_scan(load_selections(), load_allowlist(), strict=False) == 0
783
+
784
+
785
+# --------------------------------------------------------------------------- #
786
+# CLI behaviour
787
+# --------------------------------------------------------------------------- #
788
+
789
+
790
+@pytest.fixture
791
+def fake_repo(tmp_path, monkeypatch):
792
+ """Point the module at a throwaway tree so the changed-files path can be
793
+ exercised without writing test files into the real repo."""
794
+ mod = checker
795
+ (tmp_path / "tests" / "kernels").mkdir(parents=True)
796
+ (tmp_path / "tests" / "kernels" / "test_new.py").write_text("def test_x(): pass\n")
797
+ monkeypatch.setattr(mod, "REPO_ROOT", tmp_path)
798
+ monkeypatch.setattr(mod, "TESTS_DIR", tmp_path / "tests")
799
+ monkeypatch.setattr(mod, "ALLOWLIST_PATH", tmp_path / "allowlist.txt")
800
+ # The throwaway tree is not a git checkout, so the tracked-file inventory
801
+ # has to be stubbed rather than shelled out.
802
+ monkeypatch.setattr(mod, "all_test_modules", lambda: ["tests/kernels/test_new.py"])
803
+ return tmp_path
804
+
805
+
806
+def test_untethered_changed_file_fails(fake_repo, capsys):
807
+ rc = run_changed_files_check(["tests/kernels/test_new.py"], [], set())
808
+ assert rc == 1
809
+ assert "is not run by any Buildkite job" in capsys.readouterr().out
810
+
811
+
812
+def test_tethered_changed_file_passes(fake_repo):
813
+ selections = _parse_command("pytest -v -s kernels")
814
+ assert (
815
+ run_changed_files_check(["tests/kernels/test_new.py"], selections, set()) == 0
816
+ )
817
+
818
+
819
+def test_allowlisted_changed_file_passes(fake_repo):
820
+ allowlist = {"tests/kernels/test_new.py"}
821
+ assert run_changed_files_check(["tests/kernels/test_new.py"], [], allowlist) == 0
822
+
823
+
824
+def test_non_test_and_missing_paths_are_ignored(fake_repo):
825
+ paths = ["tests/kernels/conftest.py", "vllm/config.py", "tests/kernels/gone.py"]
826
+ assert run_changed_files_check(paths, [], set()) == 0
827
+
828
+
829
+def test_stale_allowlist_entries_are_advisory_but_reported(fake_repo, capsys):
830
+ """Dangling entries must not block an unrelated PR, but they have to be
831
+ printed - the hook sets `verbose: true` so a passing run still shows them."""
832
+ allowlist = {"tests/kernels/gone.py"}
833
+ rc = run_full_scan(_parse_command("pytest -v -s kernels"), allowlist, strict=False)
834
+ out = capsys.readouterr().out
835
+ assert rc == 0
836
+ assert "no longer exists" in out
837
+
838
+
839
+def test_stale_allowlist_entries_are_fatal_under_all(fake_repo, capsys):
840
+ allowlist = {"tests/kernels/gone.py"}
841
+ rc = run_full_scan(_parse_command("pytest -v -s kernels"), allowlist, strict=True)
842
+ assert rc == 1
843
+ assert "no longer exists" in capsys.readouterr().out
844
+
845
+
846
+def test_now_tethered_allowlist_entry_is_reported(fake_repo, capsys):
847
+ allowlist = {"tests/kernels/test_new.py"}
848
+ rc = run_full_scan(_parse_command("pytest -v -s kernels"), allowlist, strict=True)
849
+ assert rc == 1
850
+ assert "is now tethered" in capsys.readouterr().out
851
+
852
+
853
+def test_main_passes_a_tethered_file(monkeypatch):
854
+ """The CLI's normal pre-commit path: a tethered file exits 0.
855
+
856
+ `--all` is not asserted here on purpose - it implies `strict=True`, so a
857
+ stale allowlist entry would turn unrelated cleanup into a CI failure.
858
+ """
859
+ monkeypatch.setattr(
860
+ "sys.argv",
861
+ ["check_test_tethering.py", "tests/tools/test_check_test_tethering.py"],
862
+ )
863
+ assert main() == 0
864
+
865
+
866
+def test_main_flags_an_untethered_file(monkeypatch):
867
+ # Must be named like a test module, or the check correctly skips it.
868
+ orphan = REPO_ROOT / "tests" / "test_tethering_probe_orphan.py"
869
+ orphan.write_text("def test_x(): pass\n")
870
+ monkeypatch.setattr("sys.argv", ["check_test_tethering.py", str(orphan)])
871
+ try:
872
+ assert main() == 1
873
+ finally:
874
+ orphan.unlink()
875
+
876
+
877
+def test_to_repo_relative_accepts_absolute_paths():
878
+ absolute = str(REPO_ROOT / "tests" / "kernels" / "test_a.py")
879
+ assert _to_repo_relative(absolute) == "tests/kernels/test_a.py"
tools/__init__.py
tools/pre_commit/__init__.py
tools/pre_commit/check_test_tethering.py
new
+925
@@ -0,0 +1,925 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Check that test files are wired into ("tethered to") the Buildkite CI.
4
+
5
+A test file is *tethered* when at least one Buildkite job actually collects it -
6
+some job's ``commands`` run ``pytest`` (or ``python`` / ``torchrun``, or a
7
+``find ... | xargs pytest`` pipeline) over a path that includes the file. A test
8
+that is collected by no job silently never runs: it passes review, merges, and
9
+rots.
10
+
11
+How it works
12
+------------
13
+1. Parse every job in every Buildkite pipeline yaml (and each ``mirror.amd``
14
+ sub-step), and turn each shell command into a :class:`Selection` describing
15
+ which test files it runs. The pipeline directories are read from
16
+ ``.buildkite/ci_config*.yaml`` (``job_dirs:``) - currently ``test_areas/``,
17
+ ``hardware_tests/`` and ``intel_jobs/`` across the CUDA, ROCm and Intel
18
+ pipelines - plus the legacy ``.buildkite/test-amd.yaml``.
19
+2. Build the inventory of test files under ``tests/``.
20
+3. A file is a problem if no selection runs it and it is not in the allowlist
21
+ (``tools/pre_commit/test_tethering_allowlist.txt`` - the set of pre-existing
22
+ gaps, which is only ever meant to shrink). Every allowlist entry must carry a
23
+ trailing ``# <reason>``; a bare path line is itself an error.
24
+
25
+Modes
26
+-----
27
+* **changed-files** (default): pre-commit passes the changed test files as
28
+ arguments; each one must be tethered or allowlisted.
29
+* **full scan**: ``--all``, or automatically whenever the change set touches a
30
+ pipeline yaml or the allowlist itself - because editing a job command can
31
+ orphan a test that is not otherwise in the diff. The full scan also reports
32
+ allowlist entries that have since become tethered, or whose file is gone, so
33
+ the list stays honest.
34
+
35
+Coverage from *any* of those pipelines counts - the question is "does some
36
+Buildkite job run this test", not "which pipeline". ``test-amd.yaml`` is being
37
+folded into ``test_areas/`` a group at a time; when it is gone the checker stops
38
+parsing it automatically.
39
+
40
+Usage::
41
+
42
+ python tools/pre_commit/check_test_tethering.py [FILES...]
43
+ python tools/pre_commit/check_test_tethering.py --all
44
+"""
45
+
46
+import argparse
47
+import fnmatch
48
+import shlex
49
+import subprocess
50
+import sys
51
+from dataclasses import dataclass, field
52
+from pathlib import Path
53
+
54
+import regex as re
55
+import yaml
56
+
57
+REPO_ROOT = Path(__file__).resolve().parents[2]
58
+BUILDKITE_DIR = REPO_ROOT / ".buildkite"
59
+# Fallback pipeline dir, always parsed even if ``ci_config*.yaml`` can't be read.
60
+TEST_AREAS_DIR = BUILDKITE_DIR / "test_areas"
61
+# The legacy hand-maintained AMD pipeline. It is being migrated into
62
+# ``test_areas/`` piecemeal; until that finishes, a test wired only into this
63
+# file is still genuinely run by CI, so it counts as tethered. When the file is
64
+# removed, ``_pipeline_yaml_paths()`` stops appending it automatically.
65
+TEST_AMD_YAML = BUILDKITE_DIR / "test-amd.yaml"
66
+TESTS_DIR = REPO_ROOT / "tests"
67
+ALLOWLIST_PATH = Path(__file__).resolve().parent / "test_tethering_allowlist.txt"
68
+
69
+# `pytest` options that take a value in the *next* token, so that token must not
70
+# be mistaken for a test path (e.g. `-k expr`, `--shard-id 3`). Options that only
71
+# ever take their value with `=` (`--cov=...`) don't belong here - a bare
72
+# `--cov` takes no value and would wrongly swallow the following test path.
73
+PYTEST_OPTIONS_WITH_VALUE = {
74
+ "-k", "-m", "-p", "-n", "-c", "-o", "-W", "-r",
75
+ "--shard-id", "--num-shards", "--dist", "--durations", "--maxfail",
76
+ "--timeout", "--rootdir", "--junitxml", "--tb",
77
+} # fmt: skip
78
+
79
+# `pytest` options whose value is a path that is *removed* from collection at
80
+# file granularity. ``--ignore`` takes a path prefix; ``--ignore-glob`` takes a
81
+# glob that pytest matches against the whole path with ``fnmatch`` (so its ``*``
82
+# *does* cross a ``/``), which is why the two are modelled separately.
83
+PYTEST_IGNORE_OPTIONS = {"--ignore", "--ignore-glob"}
84
+
85
+# `--deselect` takes a value too, but it removes individual node IDs - a file
86
+# with one parametrization deselected is still collected and still "run".
87
+PYTEST_DESELECT_OPTION = "--deselect"
88
+
89
+PYTEST_COMMANDS = {"pytest", "py.test"}
90
+
91
+# A leading ``$VAR/`` or ``${VAR}/`` path prefix - CI shell scripts routinely
92
+# name a test by an absolute path rooted at a shell var
93
+# (``"${GIT_ROOT}/tests/v1/..."``). The prefix is dropped before the path is
94
+# interpreted; an unresolved ``$`` anywhere else still makes the token unusable.
95
+_LEADING_VAR_PREFIX = re.compile(r"^\$\{?\w+\}?/")
96
+
97
+# Commands that execute a Python file directly. Only the *first* path argument is
98
+# the script that runs; everything after it is that script's own argv, not more
99
+# test files (``python tensorize.py --suffix v1`` runs one file, not ``tests/v1``).
100
+FILE_RUNNER_COMMANDS = {"python", "python3", "torchrun", "coverage"}
101
+
102
+
103
+# --------------------------------------------------------------------------- #
104
+# Shell-token helpers
105
+# --------------------------------------------------------------------------- #
106
+
107
+
108
+def _is_env_assignment(token: str) -> bool:
109
+ """True for a leading ``FOO=bar`` shell env-var assignment - the kind some
110
+ commands put before ``pytest`` (``VLLM_TEST_FORCE_LOAD_FORMAT=auto pytest``).
111
+ """
112
+ if "=" not in token:
113
+ return False
114
+ name = token.split("=", 1)[0]
115
+ return bool(name) and name.replace("_", "").isupper()
116
+
117
+
118
+def normalize_test_path(raw: str) -> str:
119
+ """Reduce a path as it appears in a command to one comparable form:
120
+ relative to ``tests/``, no surrounding quotes, no ``::nodeid`` selector, no
121
+ trailing slash.
122
+
123
+ Commands run from either the repo root or from ``tests/``, so both a
124
+ ``tests/`` and a ``/vllm-workspace/tests/`` prefix are stripped, as is a
125
+ leading ``$VAR/`` (``"${GIT_ROOT}/tests/..."``).
126
+ """
127
+ path = raw.strip().strip("'\"")
128
+ path = path.split("::", 1)[0]
129
+ path = _LEADING_VAR_PREFIX.sub("", path)
130
+ for prefix in ("./", "tests/", "/vllm-workspace/tests/"):
131
+ if path.startswith(prefix):
132
+ path = path[len(prefix) :]
133
+ return path.rstrip("/")
134
+
135
+
136
+def _token_is_test_path(token: str) -> bool:
137
+ """True if a shell token is a path argument rather than a flag, an env-var
138
+ assignment, a Buildkite ``$VAR``, or a ``-m`` / ``-k`` expression value.
139
+
140
+ A token with a ``/`` or a ``.py`` ending is always a path. A bare word
141
+ (``samplers``) only counts if it is really a directory under ``tests/``. A
142
+ leading ``$VAR/`` is dropped first, but a ``$`` anywhere else means the token
143
+ can't be resolved to a known path.
144
+ """
145
+ if not token or token.startswith("-"):
146
+ return False
147
+ if _is_env_assignment(token):
148
+ return False
149
+ token = _LEADING_VAR_PREFIX.sub("", token)
150
+ if "$" in token:
151
+ return False
152
+ if "/" in token or token.endswith(".py"):
153
+ return True
154
+ return (TESTS_DIR / normalize_test_path(token)).is_dir()
155
+
156
+
157
+def _path_is_within(parent: str, path: str) -> bool:
158
+ """True if ``path`` is ``parent`` itself or nested underneath it. A ``''`` /
159
+ ``'.'`` parent (a command run with no path arg) contains everything."""
160
+ if parent in ("", "."):
161
+ return True
162
+ return path == parent or path.startswith(parent + "/")
163
+
164
+
165
+def _glob_match_path(pattern: str, path: str) -> bool:
166
+ """Like :func:`fnmatch.fnmatch`, but ``*`` and ``?`` do not match across a
167
+ ``/`` - the way the shell and ``pytest`` treat an unexpanded glob path
168
+ argument (``kernels/test_foo_*.py`` selects one directory, not the tree).
169
+ A ``**`` component keeps the cross-separator meaning."""
170
+ if not any(ch in pattern for ch in "*?["):
171
+ return pattern == path
172
+ if "**" in pattern:
173
+ return fnmatch.fnmatch(path, pattern)
174
+ pattern_parts = pattern.split("/")
175
+ path_parts = path.split("/")
176
+ return len(pattern_parts) == len(path_parts) and all(
177
+ fnmatch.fnmatch(part, glob) for part, glob in zip(path_parts, pattern_parts)
178
+ )
179
+
180
+
181
+# --------------------------------------------------------------------------- #
182
+# Selections - "which test files does this command run?"
183
+# --------------------------------------------------------------------------- #
184
+
185
+
186
+@dataclass
187
+class PytestSelection:
188
+ """A ``pytest``-style command: run everything under ``included_paths``
189
+ except what an ``--ignore`` (path prefix) or ``--ignore-glob`` (whole-path
190
+ ``fnmatch``) removes."""
191
+
192
+ included_paths: list[str] = field(default_factory=list)
193
+ ignored_paths: list[str] = field(default_factory=list)
194
+ ignored_globs: list[str] = field(default_factory=list)
195
+
196
+ def runs(self, test_file: str) -> bool:
197
+ included = any(self._arg_covers(arg, test_file) for arg in self.included_paths)
198
+ if not included:
199
+ return False
200
+ if any(self._arg_covers(arg, test_file) for arg in self.ignored_paths):
201
+ return False
202
+ # `--ignore-glob` is a plain `fnmatch` over the whole path - `*_tp.py`
203
+ # drops `kernels/nested/test_a_tp.py`, unlike a positional glob arg.
204
+ return not any(
205
+ fnmatch.fnmatch(test_file, normalize_test_path(glob))
206
+ for glob in self.ignored_globs
207
+ )
208
+
209
+ @staticmethod
210
+ def _arg_covers(path_arg: str, test_file: str) -> bool:
211
+ """True if a single pytest path argument selects ``test_file`` - by
212
+ exact match, by being a parent directory, or as a glob."""
213
+ path_arg = normalize_test_path(path_arg)
214
+ if _path_is_within(path_arg, test_file):
215
+ return True
216
+ return _glob_match_path(path_arg, test_file)
217
+
218
+
219
+@dataclass
220
+class FindSelection:
221
+ """A ``find <root> -name ... [-not -name ...] [-maxdepth N] | xargs pytest``
222
+ pipeline. ``pytest`` runs one file per ``find`` hit, so the ``find``
223
+ expression alone defines the selected set."""
224
+
225
+ root: str
226
+ name_globs: list[str] = field(default_factory=list)
227
+ exclude_name_globs: list[str] = field(default_factory=list)
228
+ max_depth: int | None = None
229
+
230
+ def runs(self, test_file: str) -> bool:
231
+ root = normalize_test_path(self.root)
232
+ if not _path_is_within(root, test_file):
233
+ return False
234
+
235
+ # `find <root>` puts a direct child at depth 1, whether <root> is a real
236
+ # subdir ("compile") or the tests/ root itself ("" / ".").
237
+ below = test_file if root in ("", ".") else test_file[len(root) :]
238
+ depth_below_root = below.lstrip("/").count("/") + 1
239
+ if self.max_depth is not None and depth_below_root > self.max_depth:
240
+ return False
241
+
242
+ filename = test_file.rsplit("/", 1)[-1]
243
+ if self.name_globs and not _glob_any(filename, self.name_globs):
244
+ return False
245
+ return not _glob_any(filename, self.exclude_name_globs)
246
+
247
+
248
+Selection = PytestSelection | FindSelection
249
+
250
+
251
+def _glob_any(name: str, globs: list[str]) -> bool:
252
+ return any(fnmatch.fnmatch(name, glob) for glob in globs)
253
+
254
+
255
+# --------------------------------------------------------------------------- #
256
+# Parsing CI commands into selections
257
+# --------------------------------------------------------------------------- #
258
+
259
+
260
+def _parse_pytest_command(tokens: list[str]) -> PytestSelection:
261
+ """Read a ``pytest ...`` invocation's tokens into a :class:`PytestSelection`.
262
+
263
+ Positional path args are the included paths; ``--ignore`` values (and a
264
+ bare-path ``--deselect``) are ignored paths, ``--ignore-glob`` values are
265
+ ignored globs. Value-taking options and their values, and every other flag,
266
+ are skipped. ``-k`` / ``-m`` filtering is intentionally *not* modelled - a
267
+ file that a marker expression narrows is still "run".
268
+ """
269
+ selection = PytestSelection()
270
+
271
+ # Advance past the pytest token itself (and any env-var / wrapper prefix).
272
+ tokens = iter(tokens)
273
+ for token in tokens:
274
+ if token in PYTEST_COMMANDS:
275
+ break
276
+
277
+ for token in tokens:
278
+ opt, eq, inline = token.partition("=")
279
+ if opt in PYTEST_IGNORE_OPTIONS:
280
+ value = inline if eq else next(tokens, "")
281
+ if opt == "--ignore-glob":
282
+ selection.ignored_globs.append(value)
283
+ else:
284
+ selection.ignored_paths.append(value)
285
+ elif opt == PYTEST_DESELECT_OPTION:
286
+ value = inline if eq else next(tokens, "")
287
+ # A `path::node_id` deselect leaves the file collected; only a
288
+ # bare-path deselect removes anything at file granularity.
289
+ if value and "::" not in value:
290
+ selection.ignored_paths.append(value)
291
+ elif token in PYTEST_OPTIONS_WITH_VALUE:
292
+ next(tokens, None) # consume and discard the option's value
293
+ elif token.startswith("-"):
294
+ continue
295
+ elif _token_is_test_path(token):
296
+ selection.included_paths.append(token)
297
+
298
+ return selection
299
+
300
+
301
+def _parse_find_command(tokens: list[str]) -> FindSelection | None:
302
+ """Read a ``find <root> -name ...`` command's tokens into a
303
+ :class:`FindSelection`, or return ``None`` if there is no usable ``find``."""
304
+ if "find" not in tokens:
305
+ return None
306
+
307
+ root: str | None = None
308
+ name_globs: list[str] = []
309
+ exclude_name_globs: list[str] = []
310
+ max_depth: int | None = None
311
+
312
+ tokens = iter(tokens[tokens.index("find") + 1 :])
313
+ negated = False
314
+ for token in tokens:
315
+ if token in ("-not", "!"):
316
+ negated = True
317
+ continue
318
+ if token in ("-name", "-iname"):
319
+ glob = next(tokens, "").strip("'\"")
320
+ (exclude_name_globs if negated else name_globs).append(glob)
321
+ elif token == "-maxdepth":
322
+ depth = next(tokens, "")
323
+ max_depth = int(depth) if depth.isdigit() else None
324
+ elif (
325
+ root is None and not token.startswith("-") and token not in PYTEST_COMMANDS
326
+ ):
327
+ root = token
328
+ negated = False
329
+
330
+ if root is None:
331
+ return None
332
+ return FindSelection(root, name_globs, exclude_name_globs, max_depth)
333
+
334
+
335
+def _dash_c_code_operand(tokens: list[str]) -> str | None:
336
+ """For ``bash -c '<code>' [$0] [args]`` / ``sh -c ...`` / a combined short
337
+ group with ``c`` anywhere in it (``-ec``, ``-ce``, ``-euxc``), the ``<code>``
338
+ string the shell runs. ``None`` when this is not a ``-c`` invocation (plain
339
+ ``bash script.sh`` / ``bash -x script.sh``). Everything after the code
340
+ operand is a positional parameter (``$0``, ``$1``, ...), not a script or
341
+ command that executes."""
342
+ if not tokens or tokens[0].rsplit("/", 1)[-1] not in ("bash", "sh"):
343
+ return None
344
+ for i, token in enumerate(tokens[1:], start=1):
345
+ if not token.startswith("-"):
346
+ return None # script operand reached; a later `-c` is *its* flag
347
+ # a short-option group (single dash) with `c` among its flags.
348
+ if not token.startswith("--") and "c" in token[1:]:
349
+ return tokens[i + 1] if i + 1 < len(tokens) else None
350
+ return None
351
+
352
+
353
+def _parse_shell_script(tokens: list[str], visited: set[str]) -> list[Selection]:
354
+ """Follow a ``bash tests/foo.sh`` command: read the script and parse the
355
+ pytest / runner lines inside it. ``visited`` stops a script that (directly or
356
+ otherwise) refers back to itself; nesting is one level deep in practice.
357
+ """
358
+ if _dash_c_code_operand(tokens) is not None:
359
+ return [] # the `-c` code string is parsed by the caller, not a file
360
+ script_arg = next((t for t in tokens if t.endswith(".sh")), None)
361
+ if script_arg is None:
362
+ return []
363
+
364
+ rel = script_arg.strip().strip("'\"").replace("/vllm-workspace/", "")
365
+ rel = rel.replace("vllm-workspace/", "").removeprefix("./").removeprefix("/")
366
+ candidates = [REPO_ROOT / rel, TESTS_DIR / normalize_test_path(rel)]
367
+ script_path = next((p for p in candidates if p.is_file()), None)
368
+ if script_path is None or str(script_path) in visited:
369
+ return []
370
+ visited.add(str(script_path))
371
+
372
+ # Join `\`-continued lines (a `pytest ... \` with its path on the next line
373
+ # is common), then track literal `VAR=value` assignments so a later
374
+ # `pytest "$VAR"` - the import-canary pattern - resolves to a real path.
375
+ text = script_path.read_text().replace("\\\n", " ")
376
+ var_values: dict[str, str] = {}
377
+ selections: list[Selection] = []
378
+ for line in text.splitlines():
379
+ line = line.strip()
380
+ if not line or line.startswith("#"):
381
+ continue
382
+ assignment = re.match(r"^(\w+)=(\S+)$", line)
383
+ if assignment:
384
+ var_values[assignment.group(1)] = assignment.group(2).strip("'\"")
385
+ continue
386
+ if var_values:
387
+ line = _expand_vars(line, var_values)
388
+ selections.extend(_parse_command(line, visited))
389
+ return selections
390
+
391
+
392
+def _expand_vars(line: str, var_values: dict[str, str]) -> str:
393
+ """Substitute known ``$VAR`` / ``${VAR}`` in a script line - but not inside
394
+ single quotes, where the shell does not expand (``pytest '$X'`` passes a
395
+ literal ``$X``, collecting nothing)."""
396
+
397
+ def sub(segment: str) -> str:
398
+ return re.sub(
399
+ r"\$\{(\w+)\}|\$(\w+)",
400
+ lambda m: var_values.get(m.group(1) or m.group(2), m.group(0)),
401
+ segment,
402
+ )
403
+
404
+ # Splitting on `'`: even-indexed segments are outside single quotes, odd are
405
+ # inside. (A `'` within double quotes splits wrongly, but that only ever
406
+ # drops a substitution - the safe direction.)
407
+ parts = line.split("'")
408
+ return "'".join(sub(p) if i % 2 == 0 else p for i, p in enumerate(parts))
409
+
410
+
411
+# Shell tokens (or runs of them) that separate one sub-command from the next.
412
+_SHELL_SEP_CHARS = set("|&;()<>")
413
+_RUNNER_KEYWORDS = PYTEST_COMMANDS | FILE_RUNNER_COMMANDS
414
+
415
+# Tokens that can precede the real command word without being it.
416
+_COMMAND_WRAPPERS = {"if", "!", "then", "time", "exec", "nice", "command", "builtin"}
417
+
418
+
419
+def _effective_command_index(tokens: list[str]) -> int:
420
+ """Index of the token that names the command actually executed, past leading
421
+ ``FOO=bar`` assignments and simple wrappers - ``if ! env FOO=1 bash x.sh``
422
+ resolves to the ``bash``, so ``echo x.sh`` stays ``echo`` and is not
423
+ followed. Returns ``len(tokens)`` when nothing executable is left."""
424
+ i = 0
425
+ while i < len(tokens):
426
+ token = tokens[i]
427
+ if _is_env_assignment(token) or token in _COMMAND_WRAPPERS:
428
+ i += 1
429
+ elif token == "env":
430
+ i += 1
431
+ while i < len(tokens) and (
432
+ _is_env_assignment(tokens[i]) or "$" in tokens[i] or tokens[i] == "-"
433
+ ):
434
+ i += 1
435
+ elif token == "timeout" and i + 1 < len(tokens):
436
+ i += 1
437
+ while i < len(tokens) and tokens[i].startswith("-"):
438
+ # `-k` / `--kill-after` / `-s` / `--signal` take a value.
439
+ if tokens[i] in ("-k", "--kill-after", "-s", "--signal"):
440
+ i += 1
441
+ i += 1
442
+ i += 1 # the DURATION operand
443
+ else:
444
+ break
445
+ return i
446
+
447
+
448
+def _split_shell_stages(command: str) -> list[tuple[str, list[str]]]:
449
+ """Tokenize a shell line - honouring quotes - and split it at the shell
450
+ operators, tagging each stage with the operator token that *precedes* it
451
+ (``""`` for the first stage, then ``"|"`` / ``"&&"`` / ``"||"`` / ``";"`` /
452
+ ``"&"`` / a subshell paren). Knowing which operator joins two stages is what
453
+ lets a ``find`` sweep count only when it is genuinely piped into ``pytest``,
454
+ not merely on the same line as one. A line that cannot be tokenized
455
+ (unbalanced quote, ...) yields nothing: better to miss a selection than to
456
+ misparse one. Splitting *after* tokenizing means an operator inside a quoted
457
+ argument stays part of that argument."""
458
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
459
+ lexer.whitespace_split = True
460
+ try:
461
+ tokens = list(lexer)
462
+ except ValueError:
463
+ return []
464
+
465
+ stages: list[tuple[str, list[str]]] = [("", [])]
466
+ in_exec = False # inside a `find -exec ... \;` action - its `;` terminates
467
+ for token in tokens:
468
+ if in_exec and token in (";", "+"):
469
+ stages[-1][1].append(token)
470
+ in_exec = False
471
+ elif token and set(token) <= _SHELL_SEP_CHARS:
472
+ stages.append((token, []))
473
+ in_exec = False
474
+ else:
475
+ stages[-1][1].append(token)
476
+ if token in ("-exec", "-execdir"):
477
+ in_exec = True
478
+ return [(sep, sub) for sep, sub in stages if sub]
479
+
480
+
481
+def _looks_like_command_string(token: str) -> bool:
482
+ """True for a single token that is itself a command line - e.g. a quoted
483
+ ``"pytest a.py && torchrun b.py"`` passed as an argument to a runner
484
+ script."""
485
+ return " " in token and any(word in _RUNNER_KEYWORDS for word in token.split())
486
+
487
+
488
+def _classify_subcommand(tokens: list[str], visited: set[str]) -> list[Selection]:
489
+ """Map one already-tokenized sub-command to the selections it implies:
490
+ find / pytest / ``bash <script>`` / direct file runner. Anything else
491
+ contributes nothing - better to miss a real selection than to invent
492
+ coverage that isn't there."""
493
+ if not tokens:
494
+ return []
495
+
496
+ is_find = "find" in tokens and any(t in ("-name", "-iname") for t in tokens)
497
+ if is_find:
498
+ find_selection = _parse_find_command(tokens)
499
+ return [find_selection] if find_selection is not None else []
500
+
501
+ # A `pytest` token only counts when `pytest` is the command that actually
502
+ # runs - `echo pytest kernels` prints text, it does not collect anything.
503
+ if _runs_pytest(tokens):
504
+ return [_parse_pytest_command(tokens)]
505
+
506
+ # Resolve the real command word past `FOO=1` / `env` / `if ! ...` wrappers,
507
+ # so `env FOO=1 bash x.sh` is followed but `echo x.sh` (Buildkite just
508
+ # printing a filename) is not.
509
+ cmd_index = _effective_command_index(tokens)
510
+ command_name = (
511
+ tokens[cmd_index].rsplit("/", 1)[-1] if cmd_index < len(tokens) else ""
512
+ )
513
+
514
+ if command_name in ("bash", "sh") or command_name.endswith(".sh"):
515
+ run_tokens = tokens[cmd_index:]
516
+ code = _dash_c_code_operand(run_tokens)
517
+ if code is not None:
518
+ # `bash -c '<code>' [$0] [args]` - only the `-c` string runs; the
519
+ # trailing args are positional params, not commands.
520
+ return _parse_command(code, visited)
521
+ selections = _parse_shell_script(run_tokens, visited)
522
+ # Runner scripts (run-multi-node-test.sh, ...) take the real test
523
+ # commands as quoted string arguments - parse those too.
524
+ for arg in run_tokens[1:]:
525
+ if _looks_like_command_string(arg):
526
+ selections.extend(_parse_command(arg, visited))
527
+ return selections
528
+
529
+ # `python` / `torchrun` / `coverage <file>.py`.
530
+ if command_name in FILE_RUNNER_COMMANDS:
531
+ rest = tokens[cmd_index + 1 :]
532
+ # `python -c '<code>' [argv...]` runs the code string, not a file - a
533
+ # later `-m pytest` or `.py` is that program's argv. Only honour a `-c`
534
+ # that is the interpreter's own (nothing script-like precedes it).
535
+ if "-c" in rest:
536
+ before_c = rest[: rest.index("-c")]
537
+ if "-m" not in before_c and not any(t.endswith(".py") for t in before_c):
538
+ return []
539
+ # `-m <module>` picks the module, not a script: only `-m pytest` runs
540
+ # tests. `python -m compileall kernels` collects nothing - the trailing
541
+ # `kernels` is compileall's argv, not a test path.
542
+ if "-m" in rest:
543
+ after_m = rest[rest.index("-m") + 1 :]
544
+ if after_m[:1] and after_m[0] in PYTEST_COMMANDS:
545
+ return [_parse_pytest_command(after_m)]
546
+ return []
547
+ # `python foo.py a b/c --suffix v1` executes only `foo.py`; the tokens
548
+ # after it are that script's argv - treating them as test paths tethers
549
+ # whatever directory an argument happens to name (`v1` -> `tests/v1`).
550
+ script = next(
551
+ (t for t in rest if t.endswith(".py") and _token_is_test_path(t)), None
552
+ )
553
+ if script is not None:
554
+ return [PytestSelection(included_paths=[script])]
555
+
556
+ return []
557
+
558
+
559
+# `xargs` options that consume the following token as their value.
560
+_XARGS_VALUE_FLAGS = {
561
+ "-n", "-L", "-P", "-s", "-I", "-i", "-J", "-E", "-d",
562
+ "--max-args", "--max-lines", "--max-procs", "--replace", "--delimiter",
563
+} # fmt: skip
564
+
565
+
566
+def _xargs_command_tokens(tokens: list[str]) -> list[str]:
567
+ """The command (plus its args) that ``xargs`` will exec, with ``xargs``' own
568
+ options stripped: ``xargs -0 -n1 -I{} env FOO=1 pytest`` -> ``['env',
569
+ 'FOO=1', 'pytest']``."""
570
+ i = 0
571
+ while i < len(tokens):
572
+ if tokens[i] in _XARGS_VALUE_FLAGS:
573
+ i += 2
574
+ elif tokens[i].startswith("-"):
575
+ i += 1
576
+ else:
577
+ return tokens[i:]
578
+ return []
579
+
580
+
581
+def _runs_pytest(tokens: list[str]) -> bool:
582
+ """True if ``tokens`` is a command that executes ``pytest``, seeing through
583
+ ``env FOO=1`` / ``timeout`` / ``if !`` wrappers (``xargs echo pytest`` is
584
+ not - ``echo`` is what runs)."""
585
+ idx = _effective_command_index(tokens)
586
+ return idx < len(tokens) and tokens[idx] in PYTEST_COMMANDS
587
+
588
+
589
+# Pipe stages that reorder or echo the stream without dropping any line, so
590
+# every `find` match still reaches a downstream `xargs pytest`. Anything else
591
+# (`head`, `grep`, `shuf`, `sed -n`, ...) filters, and then only some matches
592
+# run - not enough to claim the whole sweep is tethered.
593
+_PIPE_PASSTHROUGH = {"sort", "tee", "cat", "tac"}
594
+
595
+
596
+def _find_feeds_pytest(stages: list[tuple[str, list[str]]], find_index: int) -> bool:
597
+ """True if the ``find`` stage at ``find_index`` actually runs its matches
598
+ through ``pytest``: a *terminated* ``-exec pytest ... \\;`` / ``... +`` in the
599
+ ``find`` itself, or an ``xargs`` whose command operand is ``pytest``, reached
600
+ through ``|`` pipes of non-filtering stages only. A ``find`` piped to
601
+ ``wc`` / ``head`` / an ``xargs echo``, an unterminated ``-exec``, or a
602
+ ``pytest`` in a separate ``;`` / ``&&`` command, runs nothing (or not the
603
+ whole sweep)."""
604
+ find_tokens = stages[find_index][1]
605
+ for i, token in enumerate(find_tokens):
606
+ if token in ("-exec", "-execdir"):
607
+ action = find_tokens[i + 1 :]
608
+ if _exec_runs_matches_through_pytest(action):
609
+ return True
610
+ for sep, tokens in stages[find_index + 1 :]:
611
+ if sep != "|":
612
+ break
613
+ if tokens[:1] == ["xargs"] and _runs_pytest(_xargs_command_tokens(tokens[1:])):
614
+ return True
615
+ if tokens[:1] and tokens[0] in _PIPE_PASSTHROUGH:
616
+ continue
617
+ break
618
+ return False
619
+
620
+
621
+def _terminated(exec_action: list[str]) -> bool:
622
+ """A ``find -exec`` action only runs if it ends with a ``;`` or ``+`` token
623
+ (from ``\\;`` / ``+`` on the command line)."""
624
+ return ";" in exec_action or "+" in exec_action
625
+
626
+
627
+def _exec_runs_matches_through_pytest(action: list[str]) -> bool:
628
+ """True if a ``find -exec``/``-execdir`` action runs ``pytest`` over each
629
+ *matched* file: ``pytest`` is the command, the action is terminated
630
+ (``\\;`` / ``+``), and the ``{}`` placeholder lands as a positional test-path
631
+ argument - not as the value of an option (``-exec pytest --ignore {} \\;``
632
+ only ever runs whatever positional file is also on the line)."""
633
+ if not action or action[0] not in PYTEST_COMMANDS or not _terminated(action):
634
+ return False
635
+ # Substitute a real-looking path for `{}` and see whether pytest's own arg
636
+ # parser keeps it as an included path rather than eating it as an option value.
637
+ probe = [t.replace("{}", "_find_match_.py") for t in action[:-1]]
638
+ selection = _parse_pytest_command(probe)
639
+ return any("_find_match_" in path for path in selection.included_paths)
640
+
641
+
642
+def _parse_command(command: str, visited: set[str] | None = None) -> list[Selection]:
643
+ """Turn one yaml ``commands:`` entry into the selections it implies.
644
+
645
+ The line is tokenized (respecting quotes) and split into stages at the shell
646
+ operators, and each stage is classified independently so that, e.g., a
647
+ ``find`` and a ``pytest`` on the same line are both handled. A ``find -name``
648
+ sweep is kept only when :func:`_find_feeds_pytest` confirms its output
649
+ actually reaches ``pytest`` - otherwise it would invent coverage.
650
+ """
651
+ visited = visited if visited is not None else set()
652
+ stages = _split_shell_stages(command)
653
+ selections: list[Selection] = []
654
+ for index, (_, tokens) in enumerate(stages):
655
+ for selection in _classify_subcommand(tokens, visited):
656
+ if isinstance(selection, FindSelection) and not _find_feeds_pytest(
657
+ stages, index
658
+ ):
659
+ continue
660
+ selections.append(selection)
661
+ return selections
662
+
663
+
664
+def _iter_job_steps(yaml_doc: dict):
665
+ """Yield each job step in a test_areas yaml, plus a merged view of each
666
+ ``mirror.*`` sub-step. The AMD mirror can override ``commands``, so it has to
667
+ be checked as its own step."""
668
+ for step in yaml_doc.get("steps") or []:
669
+ if not isinstance(step, dict):
670
+ continue
671
+ yield step
672
+
673
+ for mirror_step in (step.get("mirror") or {}).values():
674
+ if isinstance(mirror_step, dict):
675
+ merged = {**step, **mirror_step}
676
+ merged.pop("mirror", None)
677
+ yield merged
678
+
679
+
680
+def _pipeline_job_dirs() -> list[Path]:
681
+ """The Buildkite job directories, read from every ``.buildkite/ci_config*.yaml``
682
+ (``job_dirs:``). Buildkite itself uses these files to assemble each pipeline,
683
+ so a test wired into any job under any of them is genuinely run by CI. Falls
684
+ back to ``test_areas/`` alone if no config is readable."""
685
+ job_dirs: set[Path] = {TEST_AREAS_DIR}
686
+ for config in sorted((REPO_ROOT / ".buildkite").glob("ci_config*.yaml")):
687
+ try:
688
+ doc = yaml.safe_load(config.read_text()) or {}
689
+ except yaml.YAMLError:
690
+ continue
691
+ for rel in doc.get("job_dirs") or []:
692
+ job_dirs.add(REPO_ROOT / rel)
693
+ return sorted(job_dirs)
694
+
695
+
696
+def _pipeline_yaml_paths() -> list[Path]:
697
+ """Every Buildkite pipeline yaml whose jobs count as CI coverage: the yamls
698
+ in every :func:`_pipeline_job_dirs` directory plus the legacy ``test-amd.yaml``
699
+ (see ``TEST_AMD_YAML``)."""
700
+ paths: list[Path] = []
701
+ for job_dir in _pipeline_job_dirs():
702
+ paths.extend(sorted(job_dir.glob("*.yaml")))
703
+ if TEST_AMD_YAML.is_file():
704
+ paths.append(TEST_AMD_YAML)
705
+ return paths
706
+
707
+
708
+def load_selections() -> list[Selection]:
709
+ """Parse every CI pipeline yaml into the full list of selections - the CI's
710
+ complete picture of which test files it runs.
711
+
712
+ A yaml that doesn't parse is fatal: silently skipping it would drop whatever
713
+ coverage it defines and produce false "untethered" reports.
714
+ """
715
+ selections: list[Selection] = []
716
+ for yaml_path in _pipeline_yaml_paths():
717
+ try:
718
+ yaml_doc = yaml.safe_load(yaml_path.read_text()) or {}
719
+ except yaml.YAMLError as e:
720
+ rel = yaml_path.relative_to(REPO_ROOT)
721
+ raise SystemExit(f"error: {rel} is not valid YAML: {e}") from None
722
+ if not isinstance(yaml_doc, dict):
723
+ rel = yaml_path.relative_to(REPO_ROOT)
724
+ raise SystemExit(
725
+ f"error: {rel} is not a Buildkite pipeline "
726
+ f"(top-level {type(yaml_doc).__name__}, expected a mapping)"
727
+ )
728
+ for step in _iter_job_steps(yaml_doc):
729
+ commands = list(step.get("commands") or [])
730
+ if step.get("command"): # some steps use the singular form
731
+ commands.append(step["command"])
732
+ for command in commands:
733
+ if isinstance(command, str):
734
+ selections.extend(_parse_command(command))
735
+ return selections
736
+
737
+
738
+def is_tethered(test_file: str, selections: list[Selection]) -> bool:
739
+ """True if any CI selection runs ``test_file`` (path relative to ``tests/``
740
+ or to the repo root - both are accepted)."""
741
+ normalized = normalize_test_path(test_file)
742
+ return any(selection.runs(normalized) for selection in selections)
743
+
744
+
745
+# --------------------------------------------------------------------------- #
746
+# Test-file inventory and allowlist
747
+# --------------------------------------------------------------------------- #
748
+
749
+
750
+def is_test_module(repo_path: str) -> bool:
751
+ """True if ``repo_path`` is a pytest test module CI is expected to run - a
752
+ ``tests/**/test_*.py`` or ``*_test.py``, but not ``conftest.py`` /
753
+ ``__init__.py`` or other helper modules."""
754
+ if not repo_path.startswith("tests/") or not repo_path.endswith(".py"):
755
+ return False
756
+ filename = repo_path.rsplit("/", 1)[-1]
757
+ if filename in ("conftest.py", "__init__.py"):
758
+ return False
759
+ return filename.startswith("test_") or filename.endswith("_test.py")
760
+
761
+
762
+def all_test_modules() -> list[str]:
763
+ """Every test module under ``tests/``, sorted. Prefers ``git ls-files`` so a
764
+ developer's local scratch files are never flagged; falls back to a
765
+ filesystem walk when run outside a git checkout - the CI test images bake in
766
+ ``tests/`` with no ``.git``, and there are no untracked scratch files there.
767
+ """
768
+ try:
769
+ tracked = subprocess.run(
770
+ ["git", "-C", str(REPO_ROOT), "ls-files", "tests/"],
771
+ capture_output=True,
772
+ text=True,
773
+ check=True,
774
+ ).stdout
775
+ paths = tracked.splitlines()
776
+ except (OSError, subprocess.CalledProcessError):
777
+ paths = [p.relative_to(REPO_ROOT).as_posix() for p in TESTS_DIR.rglob("*.py")]
778
+ return sorted(path for path in paths if is_test_module(path))
779
+
780
+
781
+def load_allowlist() -> set[str]:
782
+ """Read the allowlist file into a set of repo-relative paths. Blank lines and
783
+ ``#`` comments (whole-line or trailing) are ignored."""
784
+ if not ALLOWLIST_PATH.exists():
785
+ return set()
786
+
787
+ paths = set()
788
+ for line in ALLOWLIST_PATH.read_text().splitlines():
789
+ path = line.split("#", 1)[0].strip()
790
+ if path:
791
+ paths.add(path)
792
+ return paths
793
+
794
+
795
+def allowlist_entries_missing_reason() -> list[str]:
796
+ """Allowlist paths with no trailing ``# reason`` on their own line. Every gap
797
+ has to say why the test can't be wired into a job, so a bare ``path`` line
798
+ (the easy way to silence the gate) is itself an error."""
799
+ if not ALLOWLIST_PATH.exists():
800
+ return []
801
+
802
+ offenders = []
803
+ for line in ALLOWLIST_PATH.read_text().splitlines():
804
+ path, _, comment = line.partition("#")
805
+ if path.strip() and not comment.strip():
806
+ offenders.append(path.strip())
807
+ return offenders
808
+
809
+
810
+# --------------------------------------------------------------------------- #
811
+# The two check modes
812
+# --------------------------------------------------------------------------- #
813
+
814
+
815
+def run_full_scan(
816
+ selections: list[Selection], allowlist: set[str], *, strict: bool
817
+) -> int:
818
+ """Scan every test module. Untethered files that aren't allowlisted are
819
+ always errors. Allowlist entries that have become tethered, or whose file no
820
+ longer exists, are reported either way but are only errors when ``strict``
821
+ (an explicit ``--all`` run, as opposed to a PR that happened to touch CI
822
+ config - which shouldn't be blocked by unrelated allowlist cleanup).
823
+ """
824
+ untethered = [
825
+ path
826
+ for path in all_test_modules()
827
+ if path not in allowlist and not is_tethered(path, selections)
828
+ ]
829
+ now_tethered = sorted(path for path in allowlist if is_tethered(path, selections))
830
+ deleted = sorted(path for path in allowlist if not (REPO_ROOT / path).exists())
831
+
832
+ for path in untethered:
833
+ print(f"error: {path} is not run by any Buildkite job")
834
+ for path in now_tethered:
835
+ print(f"note: {path} is now tethered - remove it from the allowlist")
836
+ for path in deleted:
837
+ print(f"note: {path} no longer exists - remove it from the allowlist")
838
+
839
+ if untethered:
840
+ print(
841
+ f"\n{len(untethered)} test file(s) exist but run in no CI job. Wire "
842
+ "each into a job's `commands` in a .buildkite/ pipeline yaml, or add "
843
+ f"it to {ALLOWLIST_PATH.relative_to(REPO_ROOT)} with a reason."
844
+ )
845
+
846
+ error_count = len(untethered)
847
+ if strict:
848
+ error_count += len(now_tethered) + len(deleted)
849
+ return 1 if error_count else 0
850
+
851
+
852
+def run_changed_files_check(
853
+ paths: list[str], selections: list[Selection], allowlist: set[str]
854
+) -> int:
855
+ """Check only the given paths (what pre-commit passes). Non-test files, and
856
+ paths that don't exist on disk, are ignored."""
857
+ untethered = []
858
+ for raw_path in paths:
859
+ repo_path = _to_repo_relative(raw_path)
860
+ if not is_test_module(repo_path) or not (REPO_ROOT / repo_path).is_file():
861
+ continue
862
+ if repo_path in allowlist or is_tethered(repo_path, selections):
863
+ continue
864
+ untethered.append(repo_path)
865
+
866
+ for path in untethered:
867
+ print(f"error: {path} is not run by any Buildkite job")
868
+ if untethered:
869
+ print(
870
+ "\nAdd the test to a job's `commands` (and `source_file_dependencies`)"
871
+ " in a .buildkite/ pipeline yaml, or, if it is intentionally not run "
872
+ f"in CI, add it to {ALLOWLIST_PATH.relative_to(REPO_ROOT)} with a "
873
+ "comment explaining why."
874
+ )
875
+ return 1 if untethered else 0
876
+
877
+
878
+def _to_repo_relative(path: str) -> str:
879
+ if Path(path).is_absolute():
880
+ path = str(Path(path).resolve().relative_to(REPO_ROOT))
881
+ return path.replace("\\", "/")
882
+
883
+
884
+def _change_set_touches_ci_config(paths: list[str]) -> bool:
885
+ """A change to a pipeline yaml, a ci_config, or the allowlist can orphan a
886
+ test that is not itself in the change set, so those changes force a full
887
+ scan."""
888
+ return any(
889
+ ".buildkite/test_areas/" in path
890
+ or ".buildkite/hardware_tests/" in path
891
+ or ".buildkite/intel_jobs/" in path
892
+ or ".buildkite/ci_config" in path
893
+ or path.endswith("test-amd.yaml")
894
+ or path.endswith("test_tethering_allowlist.txt")
895
+ for path in paths
896
+ )
897
+
898
+
899
+def main() -> int:
900
+ parser = argparse.ArgumentParser(description=__doc__)
901
+ parser.add_argument("files", nargs="*", help="test files to check")
902
+ parser.add_argument(
903
+ "--all", action="store_true", help="scan every test file in the tree"
904
+ )
905
+ args = parser.parse_args()
906
+
907
+ selections = load_selections()
908
+ allowlist = load_allowlist()
909
+
910
+ unreasoned = allowlist_entries_missing_reason()
911
+ for path in unreasoned:
912
+ print(
913
+ f"error: {ALLOWLIST_PATH.relative_to(REPO_ROOT)}: {path} has no "
914
+ "trailing '# <reason>' - say why it can't be wired into a job"
915
+ )
916
+
917
+ if args.all or _change_set_touches_ci_config(args.files):
918
+ rc = run_full_scan(selections, allowlist, strict=args.all)
919
+ else:
920
+ rc = run_changed_files_check(args.files, selections, allowlist)
921
+ return 1 if (unreasoned or rc) else 0
922
+
923
+
924
+if __name__ == "__main__":
925
+ sys.exit(main())
tools/pre_commit/test_tethering_allowlist.txt
new
+100
@@ -0,0 +1,100 @@
1
+# Allowlist for tools/pre_commit/check_test_tethering.py
2
+#
3
+# Every path below is a test file that is collected by NO Buildkite job in any
4
+# pipeline yaml the checker parses (the job_dirs of .buildkite/ci_config*.yaml -
5
+# test_areas/, hardware_tests/, intel_jobs/ - plus .buildkite/test-amd.yaml).
6
+# These are pre-existing gaps, captured when the check was first added so that
7
+# the gate could start green. The list is meant to only ever shrink.
8
+#
9
+# Rules:
10
+# * Every entry needs a trailing `# <reason>` on its own line saying why the
11
+# test isn't wired into a job. A bare path is rejected by the check.
12
+# * Do not add a new entry to unblock a PR without a real justification.
13
+# * To remove an entry: add the test to a job's `commands` (and matching
14
+# `source_file_dependencies`) in a .buildkite/ pipeline yaml, then delete the
15
+# line.
16
+# * `check_test_tethering.py --all` reports entries here that have since
17
+# become tethered, and entries whose file no longer exists.
18
+
19
+# tests/distributed/ - multi-GPU / multi-node suites, launched via torchrun or a
20
+# custom runner rather than a directory-level pytest invocation.
21
+tests/distributed/aux_output_connector/test_store.py # added by #45635 (2026-09-21), no job collects this path
22
+tests/distributed/test_dcp_a2a.py # multi-GPU DCP all-to-all; no dir-level pytest job
23
+tests/distributed/test_dcp_direct_a2a_lse_reduce.py # multi-GPU DCP all-to-all; no dir-level pytest job
24
+tests/distributed/test_eplb_events.py # multi-GPU EPLB; no dir-level pytest job
25
+tests/distributed/test_eplb_fused_moe_layer.py # multi-GPU EPLB; no dir-level pytest job
26
+tests/distributed/test_eplb_fused_moe_layer_dep_nvfp4.py # multi-GPU EPLB nvfp4; no dir-level pytest job
27
+tests/distributed/test_eplb_quant_scale_consistency.py # multi-GPU EPLB; no dir-level pytest job
28
+tests/distributed/test_expert_parallel.py # multi-GPU expert parallel; no dir-level pytest job
29
+tests/distributed/test_expert_placement.py # multi-GPU expert placement; no dir-level pytest job
30
+tests/distributed/test_file_store.py # no distributed.yaml job collects this path
31
+tests/distributed/test_flashinfer_pcie_ipc_all_reduce.py # multi-GPU PCIe IPC all-reduce (#53576); no job
32
+tests/distributed/test_kimi_linear_context_parallel.py # multi-GPU context parallel; no dir-level pytest job
33
+tests/distributed/test_kv_cache_events.py # needs multi-proc KV-events setup; no job
34
+tests/distributed/test_kvlayout.py # no distributed.yaml job collects this path
35
+tests/distributed/test_mq_connect_ip.py # no distributed.yaml job collects this path
36
+tests/distributed/test_pipeline_partition.py # no distributed.yaml job collects this path
37
+tests/distributed/test_split_group.py # no distributed.yaml job collects this path
38
+tests/distributed/test_weight_transfer_nccl_uid.py # sibling test_weight_transfer.py is tethered; not added to that job (#53784)
39
+
40
+# tests/entrypoints/cli/
41
+tests/entrypoints/cli/test_serve.py # added by #53124 (2026-09-15); no job collects tests/entrypoints/cli/
42
+
43
+# tests/kernels/
44
+tests/kernels/test_shuffle_rows.py # explicitly --ignore'd in kernels.yaml with no follow-up job
45
+
46
+# tests/lora/ - explicitly --ignore'd from the sharded LoRA run, with no
47
+# dedicated *_tp job (unlike the other ignored *_tp tests).
48
+tests/lora/test_deepseekv2_tp.py # --ignore'd from sharded LoRA run; no dedicated *_tp job
49
+tests/lora/test_qwen3moe_tp.py # --ignore'd from sharded LoRA run; no dedicated *_tp job
50
+
51
+# tests/models/ - the models_*.yaml jobs run specific paths, not all of models/.
52
+tests/models/deepseek_v32/test_sequence_parallel.py # no models_*.yaml job collects this path
53
+tests/models/test_deepseek_v4_dspark_rocm.py # added by #52362 (2026-09-21), no models_*.yaml job collects this path
54
+tests/models/test_deepseek_v4_fi_moe_ep.py # no models_*.yaml job collects this path
55
+tests/models/test_deepseek_v4_rocm_compressor_gemm_fusion.py # no models_*.yaml job collects this path
56
+tests/models/test_deepseek_v4_rocm_wo_a.py # added by #54894 (2026-09-20), no models_*.yaml job collects this path
57
+tests/models/test_deepseek_v41_replay_start.py # added by #56227 (2026-09-18), no models_*.yaml job collects this path
58
+tests/models/test_dspark_mla.py # no models_*.yaml job collects this path
59
+tests/models/test_language_model_cache_is_weak.py # no models_*.yaml job collects this path
60
+tests/models/transformers/test_layer_registry.py # no models_*.yaml job collects this path
61
+
62
+# tests/ root-level - misc.yaml runs a hand-picked set of these by name
63
+# (test_regression.py, test_envs.py, test_outputs.py, test_pooling_params.py);
64
+# the rest are unreferenced.
65
+tests/test_access_log_filter.py # root test_*.py not in misc.yaml's by-name list
66
+tests/test_attention_backend_registry.py # root test_*.py not in misc.yaml's by-name list
67
+tests/test_audio_media_size_precheck.py # root test_*.py not in misc.yaml's by-name list
68
+tests/test_cmake_utils.py # root test_*.py not in misc.yaml's by-name list
69
+tests/test_connections.py # root test_*.py not in misc.yaml's by-name list
70
+tests/test_embedded_commit.py # root test_*.py not in misc.yaml's by-name list
71
+tests/test_force_first_config.py # root test_*.py not in misc.yaml's by-name list
72
+tests/test_fxgraphcache_pickle_patch.py # root test_*.py not in misc.yaml's by-name list
73
+tests/test_logprobs.py # root test_*.py not in misc.yaml's by-name list
74
+tests/test_quantization_revision_pin.py # root test_*.py not in misc.yaml's by-name list
75
+tests/test_ray_env_utils.py # root test_*.py not in misc.yaml's by-name list
76
+tests/test_request_input_bounds.py # root test_*.py not in misc.yaml's by-name list
77
+tests/test_scalartype.py # root test_*.py not in misc.yaml's by-name list
78
+tests/test_tensorizer_revision_pin.py # root test_*.py not in misc.yaml's by-name list
79
+tests/test_triton_utils.py # root test_*.py not in misc.yaml's by-name list
80
+tests/test_version.py # root test_*.py not in misc.yaml's by-name list
81
+tests/test_zen_cpu_platform_detection.py # root test_*.py not in misc.yaml's by-name list
82
+
83
+# tests/v1/ - the v1 subtree is otherwise covered by many `pytest v1/...` jobs.
84
+tests/v1/e2e/test_replayssm_decode.py # ReplaySSM decode test (#52506) wired into no job
85
+tests/v1/ec_connector/integration/test_epd_correctness.py # run_epd_correctness_test.sh exists but no Buildkite job invokes it
86
+tests/v1/ec_connector/integration/test_nixl_failure.py # 2-GPU + NIXL E/PD integration test (#55290); no Buildkite job collects ec_connector/integration/
87
+tests/v1/kv_connector/nixl_integration/test_disagg_accuracy.py # run by intel_jobs/misc_intel.yaml's run_xpu_disagg_accuracy_test.sh, which names it via $EXP_ROOT (the script's own dir) - not statically resolvable
88
+
89
+# tests/platforms/
90
+tests/platforms/test_cpu.py # CPU-only unit test; no job targets tests/platforms/
91
+
92
+# tests/tools/
93
+tests/tools/test_config_validator.py # logic covered by the validate-config pre-commit hook; no test_areas job
94
+
95
+# tests/watermarking/ - added by #54053 (2026-09-09), no Buildkite job collects the dir yet
96
+tests/watermarking/test_detection.py # watermarking feature (#54053); no job collects tests/watermarking/
97
+tests/watermarking/test_gumbel.py # watermarking feature (#54053); no job collects tests/watermarking/
98
+tests/watermarking/test_prf.py # watermarking feature (#54053); no job collects tests/watermarking/
99
+tests/watermarking/test_watermarking.py # watermarking feature (#54053); no job collects tests/watermarking/
100
+tests/watermarking/test_watermarking_e2e.py # watermarking hardening (#56309); no job collects tests/watermarking/