diff --git a/CMakeLists.txt b/CMakeLists.txt index 1985ce08f..321a99bc3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,20 +279,38 @@ add_test(NAME serve_context_limit_classify COMMAND bash ${CMAKE_SOURCE_DIR}/test # creates. #1583 added one name for a target defined inside if(ONEBIT_NPU), which CI does not configure -- and ninja # rejects the whole command line before building anything, so `onebit` stopped being compiled for every PR. Reads the # two files, no build and no submodules; --self-test drives it. -add_test(NAME ci_targets COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/ci/ci_targets_check.py --check --root ${CMAKE_SOURCE_DIR}) -add_test(NAME ci_targets_self COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/ci/ci_targets_check.py --self-test) +# engine#1864: `find_package(Python3)` used to be at :761, long after the first `${Python3_EXECUTABLE}` users above, so those +# registrations carried an EMPTY interpreter and ctest executed each script directly -- which passes only while the file keeps +# its exec bit (a mode-only rewrite of tools/ci/hrx_build_scope.py turned that row into BAD_COMMAND). Find the interpreter +# first and register every Python test through one place: it runs the script THROUGH the interpreter, and on a machine +# without one the test is dropped with a status line instead of a .py being handed to the kernel as a program. +find_package(Python3 COMPONENTS Interpreter) +function(add_python_test name) + if(Python3_Interpreter_FOUND) + add_test(NAME ${name} COMMAND ${Python3_EXECUTABLE} ${ARGN}) + else() + message(STATUS "ctest: not registering ${name} (no Python3 interpreter)") + endif() +endfunction() +# engine#1864: the ordering rule itself, checked on the file (no configure, no build): every ${Python3_EXECUTABLE} use has +# to follow the find_package above. The nine that did not ran through their exec bit, and a mode-only rewrite of one +# script turned the row into BAD_COMMAND. +add_python_test(python_test_order_check ${CMAKE_SOURCE_DIR}/tests/python_test_order_check.py --check --root ${CMAKE_SOURCE_DIR}) +add_python_test(python_test_order_check_self ${CMAKE_SOURCE_DIR}/tests/python_test_order_check.py --self-test) +add_python_test(ci_targets ${CMAKE_SOURCE_DIR}/tools/ci/ci_targets_check.py --check --root ${CMAKE_SOURCE_DIR}) +add_python_test(ci_targets_self ${CMAKE_SOURCE_DIR}/tools/ci/ci_targets_check.py --self-test) # engine#1820: the other half of ci_targets_check -- a standalone shell test that nothing references cannot fail. # `need` is every tests/*.sh, `have` is every file that mentions its basename somewhere that can run it, and # tools/ci/unreferenced_tests.txt carries the deliberate exclusions WITH their reasons, so the list can shrink. -add_test(NAME tests_referenced COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/ci/tests_referenced_check.py --check --root ${CMAKE_SOURCE_DIR}) -add_test(NAME tests_referenced_self COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/ci/tests_referenced_check.py --self-test) +add_python_test(tests_referenced ${CMAKE_SOURCE_DIR}/tools/ci/tests_referenced_check.py --check --root ${CMAKE_SOURCE_DIR}) +add_python_test(tests_referenced_self ${CMAKE_SOURCE_DIR}/tools/ci/tests_referenced_check.py --self-test) # engine#1777: the release row's own arm, which merged unregistered -- this sweep is what found it (engine#1820). add_test(NAME release_check_hrx_init_reason COMMAND bash ${CMAKE_SOURCE_DIR}/tests/release_check_hrx_init_reason_test.sh ${CMAKE_SOURCE_DIR}/os/mini/release-check.sh) # engine#1615: every process holding the GPU/NPU device must belong to a live gpu-hold/npu-hold ticket. Three # orphans of that shape landed in two days (51 GiB of GTT for 14 h; an NPU server 70 min past a 6-min ticket; # then two lanes each starting an HRX server outside the token within one half hour). The ancestry test is the # check; the two permanent services are exempt by name. --self-test drives the decision, no device needed. -add_test(NAME device_holders COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/device_holders.py --self-test) +add_python_test(device_holders ${CMAKE_SOURCE_DIR}/tools/device_holders.py --self-test) # engine #1668: the bench preflight must pin the load mode, because `-lm none` is +17.5 % prefill at -1.6 % decode for +604 MiB of # GTT. The script existed with a test that nothing ran; a test nobody runs is a comment, so the loader/flag assertions are registered # here (the test fakes the box, so it needs no device and no ticket). @@ -300,7 +318,7 @@ add_test(NAME bench_preflight COMMAND bash ${CMAKE_SOURCE_DIR}/tools/tests/test- # engine#1702: builds a per-layer MoE expert placement table from a moe_trace, and refuses a prefill-only trace because # placement is a decode lever (5.4-6.9 % on batched prefill against dedup's 90-94 %). --self-test drives it on synthetic # traces with a known answer; no model, no device. -add_test(NAME moe_placement COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/moe_placement.py --self-test) +add_python_test(moe_placement ${CMAKE_SOURCE_DIR}/tools/moe_placement.py --self-test) # engine #1279: the version a package is built with (scripts/pack-version.sh) -- a release tag as # it stands, a candidate mapped into the ISO week of its own date with a suffix that sorts below that # release, and the ordering a candidate needs: previous release < candidate < its release. The @@ -701,15 +719,12 @@ add_test(NAME route_log COMMAND route_log_test) # case is an artefact someone would actually push, and the conservative direction -- "full" -- is the # one every uncertain case must land on, because the build being skipped is the one that catches a # broken HRX pin. Stdlib only: no git needed for the decision itself. -add_test(NAME hrx_build_scope - COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/ci/hrx_build_scope.py --self-test) -add_test(NAME copyright_check_untracked - COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/copyright_check_untracked_test.py) +add_python_test(hrx_build_scope ${CMAKE_SOURCE_DIR}/tools/ci/hrx_build_scope.py --self-test) +add_python_test(copyright_check_untracked ${CMAKE_SOURCE_DIR}/tests/copyright_check_untracked_test.py) # engine#1517: the prefill flash-attention ratio is measured in adjacent pairs and only quoted when the pairs agree, so a # loaded box yields VOID instead of a number. The self-test is the estimator's own gate and needs no GPU: it accepts # agreement, refuses disagreement, and proves a dropped run cannot shift an arm. -add_test(NAME fa_ratio_estimator - COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/fa_ratio.py --self-test) +add_python_test(fa_ratio_estimator ${CMAKE_SOURCE_DIR}/tools/fa_ratio.py --self-test) add_dependencies(onebit route_log_test) # CI builds `--target onebit` plus a fixed list in .github/workflows/ci.yml; building this check with onebit # keeps the laya_models ctest from reporting "Not Run" without a workflow change. @@ -757,8 +772,7 @@ if(ONEBIT_LAYA_ROUTE_MODEL) endif() # The registry against the reviewed not-an-alias list (docs/arch-gaps.md) and the committed pins: -# no checkouts or network. -find_package(Python3 COMPONENTS Interpreter) +# no checkouts or network. (The interpreter is found once, at :287: engine#1864.) if(Python3_Interpreter_FOUND) add_test(NAME registry_gaps COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/registry_build.py --check-gaps) # pin the guard's firing behavior: the committed registry always passes --check-gaps, so diff --git a/tests/python_test_order_check.py b/tests/python_test_order_check.py new file mode 100755 index 000000000..3733f0350 --- /dev/null +++ b/tests/python_test_order_check.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Copyright 2026 The 1bit-MONSTER Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""engine#1864: a Python test must be registered AFTER find_package(Python3). + +`add_test(... COMMAND ${Python3_EXECUTABLE} script.py ...)` before the find_package expands the interpreter to the empty +string, so `CTestTestfile.cmake` holds the script as the program. ctest then runs it through its exec bit -- and a +mode-only rewrite (tools/ci/hrx_build_scope.py, 100755 -> 100644) turns the row into BAD_COMMAND with nothing visibly +wrong in the source. Nine registrations were in that state. + +The rule is a file-order rule, so it is checked here on the file, with no configure and no build: every +`${Python3_EXECUTABLE}` use in CMakeLists.txt must come after the first `find_package(Python3`. `add_python_test(...)` +satisfies it by construction, which is the other half of the fix. +""" + +import argparse +import pathlib +import re +import sys + +USE = re.compile(r"\$\{Python3_EXECUTABLE\}") +FIND = re.compile(r"^\s*find_package\(\s*Python3\b", re.MULTILINE) + + +def _strip_comments(text: str) -> str: + """CMake comments out, line numbers preserved: the fix's own comment names the variable it forbids.""" + return "\n".join(re.sub(r"#.*$", "", line) for line in text.split("\n")) + + +def offenders(text: str): + """Line numbers of Python3_EXECUTABLE uses that precede the first find_package(Python3).""" + text = _strip_comments(text) + finds = list(FIND.finditer(text)) + if not finds: + # No find_package at all: every use is unbound. Report them all rather than pass silently. + first = None + else: + first = finds[0].start() + out = [] + for m in USE.finditer(text): + if first is None or m.start() < first: + out.append(text.count("\n", 0, m.start()) + 1) + return out + + +def check(root: pathlib.Path) -> int: + bad = 0 + for cmake in sorted(root.rglob("CMakeLists.txt")): + if any(part in {"build", ".git", "third_party"} for part in cmake.parts): + continue + text = cmake.read_text(encoding="utf-8", errors="replace") + lines = offenders(text) + for ln in lines: + print(f"FAIL {cmake.relative_to(root)}:{ln}: ${{Python3_EXECUTABLE}} used before find_package(Python3)") + bad += 1 + if bad: + print(f"FAIL {bad} registration(s) run a script through its exec bit instead of through the interpreter") + return 1 + print("ok: every ${Python3_EXECUTABLE} use follows find_package(Python3)") + return 0 + + +def self_test() -> int: + after = "find_package(Python3 COMPONENTS Interpreter)\nadd_test(NAME a COMMAND ${Python3_EXECUTABLE} s.py)\n" + before = "add_test(NAME a COMMAND ${Python3_EXECUTABLE} s.py)\nfind_package(Python3 COMPONENTS Interpreter)\n" + none = "add_test(NAME a COMMAND ${Python3_EXECUTABLE} s.py)\n" + commented = "# ${Python3_EXECUTABLE} in a comment is prose, not a use\nadd_test(NAME a COMMAND ${Python3_EXECUTABLE} s.py)\n" + helper = "find_package(Python3 COMPONENTS Interpreter)\nfunction(add_python_test n)\n add_test(NAME ${n} COMMAND ${Python3_EXECUTABLE} ${ARGN})\nendfunction()\nadd_python_test(a s.py)\n" + cases = [ + (after, [], "a use after the find_package is fine"), + (before, [1], "a use before it is an offender"), + (none, [1], "with no find_package every use is unbound"), + (helper, [], "the helper's own use comes after the find_package"), + (commented, [2], "a comment mentioning the variable is not a use"), + ] + bad = 0 + for text, want, why in cases: + got = offenders(text) + if got != want: + print(f"FAIL self-test: {why}: want {want}, got {got}") + bad += 1 + print("self-test ok" if not bad else f"self-test FAILED ({bad})") + return 1 if bad else 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--self-test", action="store_true") + ap.add_argument("--check", action="store_true") + ap.add_argument("--root", default=".") + a = ap.parse_args() + if a.self_test: + return self_test() + if a.check: + return check(pathlib.Path(a.root).resolve()) + ap.print_help() + return 2 + + +if __name__ == "__main__": + sys.exit(main())