From 9312b077ba8746c24b830cbe3974ebf612a02f17 Mon Sep 17 00:00:00 2001 From: Kenneth-Javier Ortigoza Date: Sat, 26 Sep 2026 14:43:50 +0200 Subject: [PATCH 1/3] tests: link $(VK_OBJ) into the rest of the engine-including rules Under VK=1, CFLAGS carries -DCOLI_VULKAN, so a translation unit that includes colibri.c calls coli_vk_* and does not link without backend_vulkan.o. #1728 fixed the 43 rules that `make -k test-c VK=1` reported at the time. Thirteen more still failed on dev 4e28e39. On Linux twelve stop on the same 26 undefined coli_vk_* symbols; the thirteenth, test_xdna_failure, is Windows-only and fails the same way there: - four gates that arrived in pull requests merged after #1728: test_xdna_qt_state and test_xdna_failure (#1261), test_logprob_status and test_ablate_mode (#1356); - nine on-demand rules that test-c never builds, so no such run could report them: xdna_physical_probe, test_e8x4g64_loader, and the benches bench_topp, bench_dsa_select, bench_router_select, bench_idot, bench_i4p_gidot, bench_gemv_stream and bench_mla_simd. Each now lists $(VK_OBJ) as a prerequisite and links it after $< (after backend_xdna.c for the three XDNA rules), the #1728 pattern. $(VK_OBJ) is empty unless VK=1, so default builds are unchanged. tests/test_makefile_vk_obj.py keeps it that way without a hand-kept list. It derives the hooked sources from their preprocessor conditionals on COLI_VULKAN (colibri.c, glm53.c, kimi_k3.c, and telemetry.h through colibri.c), follows quoted #includes from every .c a $(CC) link recipe compiles ($<, $^, literal files, and variables such as QWEN36_TIER_SRC), and requires $(VK_OBJ) both as a prerequisite and on the link line. -c compiles are not links, and flag variables whose definition filters -DCOLI_VULKAN out (SEGMENT_CPU_CFLAGS) are recognised from that definition. A second test fails if the scan stops finding the engines' own rules. It needs no compiler and no make. Against the previous Makefile it fails with exactly the thirteen rules above; with this change the 60 rules it says need $(VK_OBJ) are exactly the 60 that link it. Measured, Windows 11, gcc 16.1.0 (UCRT64), make 4.4.1, with the Windows Vulkan link fix (build/windows-vulkan-link) applied: - make -k test-c VK=1: exit 0; all 170 TEST_BINS build and pass, no undefined reference (four failed to link before). - The nine on-demand targets link and import vulkan-1.dll; bench_idot with ARCH=native, which its own #error requires on any build. - make check (default build, this change alone on dev): exit 0; 170 C test binaries with no failure, 1571 Python tests OK (192 skipped). Measured, WSL Ubuntu 24.04, gcc 13.3.0, make 4.3, libvulkan-dev 1.3.275.0, glslc 2023.8: - The new test fails on the previous Makefile with exactly the thirteen rules, and passes with this change. - make -k test-c VK=1: exit 0; all 170 TEST_BINS build and pass, no undefined reference; test_xdna_qt_state now defines 31 coli_vk_* symbols. - The eight portable on-demand targets link. xdna_physical_probe, a Windows tool, is left with GetCurrentProcessId undefined and no coli_vk_* reference. Not verified: macOS (MoltenVK), where #1728 was measured; the MinGW-hosted CUDA and METAL=1 variants of these rules; running the benches or the XDNA probe, which were only built. --- c/Makefile | 52 ++++---- c/tests/test_makefile_vk_obj.py | 205 ++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 c/tests/test_makefile_vk_obj.py diff --git a/c/Makefile b/c/Makefile index 370160e7f..ddc3ea5d7 100644 --- a/c/Makefile +++ b/c/Makefile @@ -1354,8 +1354,8 @@ tests/test_ue8m0$(EXE): tests/test_ue8m0.c st.h json.h compat.h # Compiles the engine with -DCOLI_XDNA so the QT side pointer, its reset helper # and the expert-slot wiring are exercised as the engine actually builds them. -tests/test_xdna_qt_state$(EXE): tests/test_xdna_qt_state.c colibri.c backend_xdna.c backend_xdna.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h sample.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) -DCOLI_XDNA $< backend_xdna.c -o $@ $(LDFLAGS) +tests/test_xdna_qt_state$(EXE): tests/test_xdna_qt_state.c colibri.c backend_xdna.c backend_xdna.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) -DCOLI_XDNA $< backend_xdna.c $(VK_OBJ) -o $@ $(LDFLAGS) tests/test_xdna_prepared_state$(EXE): tests/test_xdna_prepared_state.c backend_xdna.c backend_xdna.h compat.h $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) @@ -1381,8 +1381,8 @@ tests/test_xdna_execution$(EXE): tests/test_xdna_execution.c backend_xdna.c back # Failure/fallback owner. Needs BOTH the engine (matmul_qt is the fallback # target it verifies against) and the synthetic ABI-2 helpers, which is why it # is a separate owner from test_xdna_execution. -tests/test_xdna_failure$(EXE): tests/test_xdna_failure.c colibri.c backend_xdna.c backend_xdna.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h sample.h kv_persist.h telemetry.h route_trace.h $(XDNA_FAKE_HELPERS) - $(CC) $(CFLAGS) -DCOLI_XDNA $< backend_xdna.c -o $@ $(LDFLAGS) +tests/test_xdna_failure$(EXE): tests/test_xdna_failure.c colibri.c backend_xdna.c backend_xdna.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h sample.h kv_persist.h telemetry.h route_trace.h $(XDNA_FAKE_HELPERS) $(VK_OBJ) + $(CC) $(CFLAGS) -DCOLI_XDNA $< backend_xdna.c $(VK_OBJ) -o $@ $(LDFLAGS) # NOT a test gate -- intentionally absent from TEST_BINS (its name does not match # the tests/test_*$(EXE) pattern the gate list is derived from). It needs a real @@ -1393,8 +1393,8 @@ tests/test_xdna_failure$(EXE): tests/test_xdna_failure.c colibri.c backend_xdna. # # M-list defaults to 1,32,64 (the M64 bucket); pass 65,130,256 to qualify # the M256 bucket through the same owner. -tests/xdna_physical_probe$(EXE): tests/xdna_physical_probe.c colibri.c backend_xdna.c backend_xdna.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h sample.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) -DCOLI_XDNA $< backend_xdna.c -o $@ $(LDFLAGS) +tests/xdna_physical_probe$(EXE): tests/xdna_physical_probe.c colibri.c backend_xdna.c backend_xdna.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) -DCOLI_XDNA $< backend_xdna.c $(VK_OBJ) -o $@ $(LDFLAGS) tests/test_json$(EXE): tests/test_json.c json.h $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) @@ -1540,14 +1540,14 @@ tests/test_grammar_cache$(EXE): tests/test_grammar_cache.c sse41_kernels.h colib # agreement check against the plain logprob_target the sampling path uses # on rows a float subtraction cannot mis-round, and a double-precision # check against a second double computation on rows that do. -tests/test_logprob_status$(EXE): tests/test_logprob_status.c colibri.c sse41_kernels.h exact_dot.h oracle.h sample.h evidence_digest.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/test_logprob_status$(EXE): tests/test_logprob_status.c colibri.c sse41_kernels.h exact_dot.h oracle.h sample.h evidence_digest.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) # The ablation mode's manifest loader, evidence writer and dispatch contract. # The adapter build bypasses only the model computation, so the parser/writer # path is exercised for real with no model and no weights. -tests/test_ablate_mode$(EXE): tests/test_ablate_mode.c colibri.c sse41_kernels.h exact_dot.h oracle.h sample.h evidence_digest.h abl.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/test_ablate_mode$(EXE): tests/test_ablate_mode.c colibri.c sse41_kernels.h exact_dot.h oracle.h sample.h evidence_digest.h abl.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) # Standalone: drives a faithful miniature of moe()'s routing+accumulate and links # the SAME abl.h the engine links -- no model/weights needed (the ablation-logic gate). @@ -1648,8 +1648,8 @@ tests/test_topp$(EXE): tests/test_topp.c sse41_kernels.h colibri.c oracle.h st.h # bench_topp is a microbenchmark (old qsort vs new heap partial-select, #335), NOT a test # gate -- intentionally absent from TEST_BINS. Build on demand: make tests/bench_topp -tests/bench_topp$(EXE): tests/bench_topp.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/bench_topp$(EXE): tests/bench_topp.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) tests/test_sample_nan$(EXE): tests/test_sample_nan.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) @@ -2168,13 +2168,13 @@ tests/test_ssd_probe$(EXE): tests/test_ssd_probe.c sse41_kernels.h colibri.c ora # bench_dsa_select is a microbenchmark (old qsort vs new quickselect partial-select, #356), # NOT a test gate -- intentionally absent from TEST_BINS. Build on demand: make tests/bench_dsa_select -tests/bench_dsa_select$(EXE): tests/bench_dsa_select.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/bench_dsa_select$(EXE): tests/bench_dsa_select.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) # bench_router_select is a microbenchmark (duplicate-prefix scan vs marked-score scan), # not a test gate. Build on demand: make tests/bench_router_select. -tests/bench_router_select$(EXE): tests/bench_router_select.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/bench_router_select$(EXE): tests/bench_router_select.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) # bench_indexer_allocations: microbenchmark (DeepSeek V4 indexer malloc vs persistent arena scratch), NOT a test gate. # Build on demand: make tests/bench_indexer_allocations @@ -2183,24 +2183,24 @@ tests/bench_indexer_allocations$(EXE): tests/bench_indexer_allocations.c # bench_idot: microbenchmark (single-acc vs independent-acc AVX-VNNI idot), NOT a test gate. # Build on demand on an AVX-VNNI CPU: make tests/bench_idot ARCH=native -tests/bench_idot$(EXE): tests/bench_idot.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/bench_idot$(EXE): tests/bench_idot.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) # bench_i4p_gidot: microbenchmark (per-row vs multi-row/AMX K1b grouped planar IDOT), NOT a test gate. # Build on demand: make tests/bench_i4p_gidot ARCH=native -tests/bench_i4p_gidot$(EXE): tests/bench_i4p_gidot.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/bench_i4p_gidot$(EXE): tests/bench_i4p_gidot.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) # bench_gemv_stream: microbenchmark (decode-regime GEMV bandwidth vs the read ceiling; # frozen-baseline + deinterleaved-x candidate A/B), NOT a test gate. # Build on demand: make tests/bench_gemv_stream ARCH=native -tests/bench_gemv_stream$(EXE): tests/bench_gemv_stream.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/bench_gemv_stream$(EXE): tests/bench_gemv_stream.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) # bench_mla_simd: microbenchmark (scalar vs AVX2/NEON MLA-absorb reductions, #442), # NOT a test gate. Build on demand: make tests/bench_mla_simd -tests/bench_mla_simd$(EXE): tests/bench_mla_simd.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/bench_mla_simd$(EXE): tests/bench_mla_simd.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) tests/test_uring$(EXE): tests/test_uring.c sse41_kernels.h colibri.c oracle.h st.h uring.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h quant.h fp8_format.h idot.h sample.h kv_persist.h telemetry.h route_trace.h $(VK_OBJ) $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) @@ -2270,8 +2270,8 @@ tests/test_shard_kvb_refuse$(EXE): tests/test_shard_kvb_refuse.c colibri.c st.h # exists so it also compiles under the suite's own $(CFLAGS) rather than only # under the driver's hand-copied flag list, and so a warning regression in it # fails the normal build. -tests/test_e8x4g64_loader$(EXE): tests/test_e8x4g64_loader.c st.h quant.h fp8_format.h compat.h - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/test_e8x4g64_loader$(EXE): tests/test_e8x4g64_loader.c st.h quant.h fp8_format.h compat.h $(VK_OBJ) + $(CC) $(CFLAGS) $< $(VK_OBJ) -o $@ $(LDFLAGS) # Reachable build-only entry point for the harness above: `make check` never # calls this (it stays out of TEST_BINS/test-c, per TEST_EXCLUDE), so this adds diff --git a/c/tests/test_makefile_vk_obj.py b/c/tests/test_makefile_vk_obj.py new file mode 100644 index 000000000..2891a7bef --- /dev/null +++ b/c/tests/test_makefile_vk_obj.py @@ -0,0 +1,205 @@ +"""A rule that compiles a Vulkan-hooked engine must link $(VK_OBJ). + +Under VK=1, CFLAGS carries -DCOLI_VULKAN. A translation unit that includes an +engine with `#ifdef COLI_VULKAN` hooks (colibri.c, kimi_k3.c, ...) then calls +coli_vk_*, and the link fails unless the rule also links $(VK_OBJ). That +variable is empty unless VK=1, so the default build and every default CI job +stay green while `make test-c VK=1` is broken. #1728 fixed the 43 rules that +`make -k test-c VK=1` reported. Four more gates (test_xdna_qt_state, +test_xdna_failure, test_logprob_status, test_ablate_mode) arrived in pull +requests merged after it, and nine on-demand rules outside TEST_BINS -- the +benches, the XDNA physical probe, the e8x4g64 loader harness -- were never +built by test-c, so no such run could report them. + +Nothing here is a hand-kept list. The hooked sources are the files with a +preprocessor conditional on COLI_VULKAN. A link recipe needs $(VK_OBJ) when a +.c file it compiles reaches one of them through quoted #includes, and the rule +must list $(VK_OBJ) as a prerequisite too: otherwise a clean build can reach +the link before backend_vulkan.o exists (#1728 found six rules like that). Flag +variables whose definition filters -DCOLI_VULKAN out, such as +SEGMENT_CPU_CFLAGS, are recognised from that definition, and `-c` compiles +are not links. +""" +import re +import unittest +from pathlib import Path + +C_DIR = Path(__file__).resolve().parent.parent + +VK_HOOK_RE = re.compile( + r"(?m)^[ \t]*#[ \t]*(?:if|ifdef|ifndef|elif)\b.*\bCOLI_VULKAN\b") +INCLUDE_RE = re.compile(r'(?m)^[ \t]*#[ \t]*include[ \t]*"([^"]+)"') +ASSIGN_RE = re.compile( + r"^(?:override[ \t]+)?([A-Za-z0-9_]+)[ \t]*(?:\+|\?|!|::?)?=[ \t]*(.*)$") +RULE_RE = re.compile(r"^([^\t#][^=]*?)[ \t]*:(?![=:])[ \t]*(.*)$") +VAR_RE = re.compile(r"\$\(([A-Za-z0-9_]+)\)") +# Lines that are not rules even when they contain a colon, e.g. +# `$(warning mixed HIP_ARCH list: ...)`. A rule may still START with `$(`: +# `$(SEGMENT_BUILD_DIR)/glm.o: colibri.c ...`. +DIRECTIVE_RE = re.compile( + r"^(?:ifeq|ifneq|ifdef|ifndef|else|endif|(?:-|s)?include|export" + r"|unexport|vpath)\b|^\$\((?:error|warning|info|file|shell|eval|call)\b") + + +def _parse_makefile(): + """(rules, variables) from c/Makefile, continuations folded. + + rules: [(targets, prerequisites, recipe_lines)], prerequisites being the + normal ones only (an order-only $(VK_OBJ) would not relink on change). + variables: name -> every value it is assigned anywhere; the union is + enough to find .c files and COLI_VULKAN filters, whatever the platform. + """ + text = (C_DIR / "Makefile").read_text(encoding="utf-8") + text = re.sub(r"\\\n[ \t]*", " ", text) + rules, variables, current, in_define = [], {}, None, False + for line in text.splitlines(): + if in_define: + in_define = not line.startswith("endef") + continue + if line.startswith("define "): + in_define, current = True, None + continue + if line.startswith("\t"): + if current is not None: + current[2].append(line.strip()) + continue + stripped = line.split("#", 1)[0].strip() + if not stripped: + continue + assign = ASSIGN_RE.match(stripped) + if assign: + variables.setdefault(assign.group(1), []).append(assign.group(2)) + current = None + continue + if DIRECTIVE_RE.match(stripped): + continue + rule = RULE_RE.match(stripped) + if rule: + prereqs = rule.group(2).split(";", 1)[0].split("|", 1)[0].split() + current = (rule.group(1).split(), prereqs, []) + rules.append(current) + return rules, variables + + +def _expand_words(word, variables, depth=0): + """Every word a $(VAR) can stand for, across all its definitions.""" + match = VAR_RE.fullmatch(word) + if not match or depth > 8: + return [word] + words = [] + for value in variables.get(match.group(1), []): + for part in value.split(): + words.extend(_expand_words(part, variables, depth + 1)) + return words + + +def _vk_stripping_vars(variables): + return {name for name, values in variables.items() + if any("filter-out" in v and "-DCOLI_VULKAN" in v for v in values)} + + +class _IncludeGraph: + """Quoted-#include reachability, reading each file once.""" + + def __init__(self): + self._files = {} + + def _scan(self, path): + if path not in self._files: + text = path.read_text(encoding="utf-8", errors="replace") + includes = [] + for name in INCLUDE_RE.findall(text): + for base in (path.parent, C_DIR): + candidate = (base / name).resolve() + if candidate.is_file(): + includes.append(candidate) + break + self._files[path] = (bool(VK_HOOK_RE.search(text)), includes) + return self._files[path] + + def reaches_vk_hook(self, source): + """True when `source` or anything it quote-includes has a hook.""" + seen, stack = set(), [source.resolve()] + while stack: + path = stack.pop() + if path in seen or not path.is_file(): + continue + seen.add(path) + hooked, includes = self._scan(path) + if hooked: + return True + stack.extend(includes) + return False + + +def _rules_needing_vk_obj(): + """[(target, has_prerequisite, has_link_argument)] for every rule whose + link recipe compiles a translation unit that reaches a COLI_VULKAN hook.""" + rules, variables = _parse_makefile() + stripping = _vk_stripping_vars(variables) + graph = _IncludeGraph() + found = [] + for targets, prereqs, recipe in rules: + for line in recipe: + words = line.split() + if "$(CC)" not in words or "-c" in words or "-UCOLI_VULKAN" in words: + continue + if stripping & {m.group(1) for m in map(VAR_RE.fullmatch, words) if m}: + continue + sources = [] + for word in words: + if word == "$<": + sources.extend(prereqs[:1]) + elif word == "$^": + sources.extend(prereqs) + else: + sources.extend(_expand_words(word, variables)) + if any(s.endswith(".c") and graph.reaches_vk_hook(C_DIR / s) + for s in sources): + found.append((" ".join(targets), "$(VK_OBJ)" in prereqs, + "$(VK_OBJ)" in words or "$^" in words)) + return found + + +class MakefileVkObjTest(unittest.TestCase): + def test_the_scan_is_derived_and_not_vacuous(self): + """A parse that matches nothing would pass the check below silently. + + Cross-check against the engines themselves: every NAME.c carrying a + COLI_VULKAN hook that has a `NAME$(EXE):` rule must be found by the + same scan, since that rule compiles it directly. + """ + hooked = sorted(p.stem for p in C_DIR.glob("*.c") + if VK_HOOK_RE.search(p.read_text(encoding="utf-8", + errors="replace"))) + self.assertTrue(hooked, "no .c file under c/ has a COLI_VULKAN " + "conditional -- the hook syntax changed and " + "this file now checks nothing") + rules, _ = _parse_makefile() + engine_rules = {name for name in hooked + if any(f"{name}$(EXE)" in t for t, _, _ in rules)} + self.assertTrue(engine_rules, f"no `NAME$(EXE):` rule for {hooked}") + found = {target for target, _, _ in _rules_needing_vk_obj()} + for name in sorted(engine_rules): + self.assertIn(f"{name}$(EXE)", found, + f"the scan missed {name}$(EXE), which compiles " + f"{name}.c itself -- the recipe parse is broken") + self.assertTrue(any(t.startswith("tests/") for t in found), + "the scan found no test rule including an engine") + + def test_every_rule_compiling_a_hooked_engine_links_vk_obj(self): + missing = [] + for target, has_prereq, has_link in _rules_needing_vk_obj(): + if not has_prereq: + missing.append(f"{target}: $(VK_OBJ) not a prerequisite") + if not has_link: + missing.append(f"{target}: $(VK_OBJ) not on the link line") + self.assertFalse( + missing, + "these rules compile a source with COLI_VULKAN hooks, so under " + "VK=1 they reference coli_vk_* and must link backend_vulkan.o:\n " + + "\n ".join(missing)) + + +if __name__ == "__main__": + unittest.main() From dddd94f619c958864b8ea87b120f52e859843608 Mon Sep 17 00:00:00 2001 From: Kenneth-Javier Ortigoza Date: Sat, 26 Sep 2026 16:08:34 +0200 Subject: [PATCH 2/3] tests: decide the VK_OBJ need with the preprocessor The first version of test_makefile_vk_obj.py read preprocessor conditionals as text: a rule needed $(VK_OBJ) when a .c it compiled reached a file with `#if ... COLI_VULKAN`. That is exact on dev, but not in general. With #1338 (the qwen36 Vulkan expert tier) merged on top, it flagged 25 rules where the linker fails one: - the qwen36 tier tests pass -DCOLI_CUDA, and qwen36_tier.c picks CUDA over Vulkan with #elif; - qwen36.c's Vulkan hook only reads COLI_VULKAN from the environment; - test_qwen36_tier_vk_fake defines the coli_vk_* functions itself. Skipping lines that pass -DCOLI_CUDA is not a fix either: kimi_k3.c keeps independent #ifdef COLI_VULKAN blocks, and test_kimi_cuda_expert does need backend_vulkan.o. The test now asks the tools. `make -Bn VK=1` gives each expanded link line, and every .c on it is preprocessed with that line's own flags (link-only and -MMD/-MF arguments dropped). A line needs $(VK_OBJ) when the surviving code of this tree, outside backend_vulkan.h, calls a function backend_vulkan.h declares and does not define it. String literals are ignored. A line this host cannot preprocess is left to the platforms that can build it. .build-config is restored afterwards, so the dry run does not make the next real build relink. The engines whose own source calls the backend anchor the non-vacuity check. Measured against the linker (make -k test-c VK=1 plus the on-demand targets): - dev 4e28e39: flags exactly the thirteen rules the previous commit fixed on Linux; on Windows twelve, since bench_idot cannot even preprocess there without ARCH=native (its #error). - dev + the previous commit: passes. On Linux the 60 lines it says call the backend are exactly the 60 that link backend_vulkan.o. - dev + #1338 + this branch: flags only test_qwen36_slot_int8, which is also the only link the linker fails there. - dev + #1758 + this branch, conflicts resolved: passes together with #1758's test_makefile_deps; with -MMD -MP in CFLAGS no .d file is written. Runtime: 15 s on Windows (gcc 16.1.0, 32 threads), 20 s on WSL Ubuntu 24.04 (gcc 13.3.0). The first version took 1.5 s. Not verified: macOS / Apple clang, where the linemarker format is the same but the test was not run. --- c/tests/test_makefile_vk_obj.py | 327 +++++++++++++++++--------------- 1 file changed, 174 insertions(+), 153 deletions(-) diff --git a/c/tests/test_makefile_vk_obj.py b/c/tests/test_makefile_vk_obj.py index 2891a7bef..b59f7ff97 100644 --- a/c/tests/test_makefile_vk_obj.py +++ b/c/tests/test_makefile_vk_obj.py @@ -1,57 +1,67 @@ -"""A rule that compiles a Vulkan-hooked engine must link $(VK_OBJ). +"""A link that calls the Vulkan backend must link $(VK_OBJ). Under VK=1, CFLAGS carries -DCOLI_VULKAN. A translation unit that includes an -engine with `#ifdef COLI_VULKAN` hooks (colibri.c, kimi_k3.c, ...) then calls -coli_vk_*, and the link fails unless the rule also links $(VK_OBJ). That -variable is empty unless VK=1, so the default build and every default CI job -stay green while `make test-c VK=1` is broken. #1728 fixed the 43 rules that +engine with Vulkan hooks (colibri.c, kimi_k3.c, ...) then calls coli_vk_*, and +the link fails unless the rule also links $(VK_OBJ). That variable is empty +unless VK=1, so the default build and every default CI job stay green while +`make test-c VK=1` is broken. #1728 fixed the 43 rules that `make -k test-c VK=1` reported. Four more gates (test_xdna_qt_state, test_xdna_failure, test_logprob_status, test_ablate_mode) arrived in pull requests merged after it, and nine on-demand rules outside TEST_BINS -- the benches, the XDNA physical probe, the e8x4g64 loader harness -- were never built by test-c, so no such run could report them. -Nothing here is a hand-kept list. The hooked sources are the files with a -preprocessor conditional on COLI_VULKAN. A link recipe needs $(VK_OBJ) when a -.c file it compiles reaches one of them through quoted #includes, and the rule -must list $(VK_OBJ) as a prerequisite too: otherwise a clean build can reach -the link before backend_vulkan.o exists (#1728 found six rules like that). Flag -variables whose definition filters -DCOLI_VULKAN out, such as -SEGMENT_CPU_CFLAGS, are recognised from that definition, and `-c` compiles -are not links. +Whether a link needs the backend is a preprocessor question, not a textual +one: code can pick CUDA over Vulkan with `#elif`, a hook can be an #ifdef that +only reads an environment variable, and a test can define the coli_vk_* +functions itself to fake the device. So nothing here reads #if lines. It asks +make for the expanded VK=1 link commands (`make -Bn`), preprocesses each .c on +a line with that line's own flags, and looks at what survives: a link needs +$(VK_OBJ) when the code calls a function backend_vulkan.h declares and does +not define it. The rule must then link $(VK_OBJ) and list it as a +prerequisite, or a clean build can reach the link before backend_vulkan.o +exists (#1728 found six rules like that). Nothing is hand-listed: the rules +come from the Makefile and the API from backend_vulkan.h. """ +import os import re +import shutil +import subprocess import unittest +from concurrent.futures import ThreadPoolExecutor from pathlib import Path C_DIR = Path(__file__).resolve().parent.parent +MAKE = shutil.which("make") -VK_HOOK_RE = re.compile( - r"(?m)^[ \t]*#[ \t]*(?:if|ifdef|ifndef|elif)\b.*\bCOLI_VULKAN\b") -INCLUDE_RE = re.compile(r'(?m)^[ \t]*#[ \t]*include[ \t]*"([^"]+)"') ASSIGN_RE = re.compile( - r"^(?:override[ \t]+)?([A-Za-z0-9_]+)[ \t]*(?:\+|\?|!|::?)?=[ \t]*(.*)$") + r"^(?:override[ \t]+)?[A-Za-z0-9_]+[ \t]*(?:\+|\?|!|::?)?=") RULE_RE = re.compile(r"^([^\t#][^=]*?)[ \t]*:(?![=:])[ \t]*(.*)$") -VAR_RE = re.compile(r"\$\(([A-Za-z0-9_]+)\)") # Lines that are not rules even when they contain a colon, e.g. -# `$(warning mixed HIP_ARCH list: ...)`. A rule may still START with `$(`: -# `$(SEGMENT_BUILD_DIR)/glm.o: colibri.c ...`. +# `$(warning mixed HIP_ARCH list: ...)`. A rule may still START with `$(`. DIRECTIVE_RE = re.compile( r"^(?:ifeq|ifneq|ifdef|ifndef|else|endif|(?:-|s)?include|export" r"|unexport|vpath)\b|^\$\((?:error|warning|info|file|shell|eval|call)\b") - - -def _parse_makefile(): - """(rules, variables) from c/Makefile, continuations folded. - - rules: [(targets, prerequisites, recipe_lines)], prerequisites being the - normal ones only (an order-only $(VK_OBJ) would not relink on change). - variables: name -> every value it is assigned anywhere; the union is - enough to find .c files and COLI_VULKAN filters, whatever the platform. +LINEMARKER_RE = re.compile(r'^#\s+\d+\s+"([^"]+)"') +STRING_RE = re.compile(r'"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\'') +NAME_RE = re.compile(r"\bcoli_vk_\w+") +DEF_RE = re.compile(r"\b(coli_vk_\w+)\s*\([^;{}()]*\)\s*\{") +# Link-only and dependency-file arguments; the rest of a link line is what +# the compile saw. -MMD/-MF would make `-E` write .d files beside the sources. +SKIP_WORD = re.compile(r"^(-l|-L|-Wl,|-static|-shared|-flto|-fuse-ld|-MMD$|-MD$|-MP$)") +SKIP_PAIR = {"-o", "-MF", "-MT", "-MQ", "-framework"} +INPUT_SUFFIXES = (".c", ".o", ".a", ".so", ".dll", ".lib", ".dylib") + + +def _link_rules(): + """{target: normal prerequisites} for every rule with a $(CC) link recipe. + + Prerequisites are the one thing `make -n` does not print. Continuations + are folded first, and define/endef bodies skipped. """ text = (C_DIR / "Makefile").read_text(encoding="utf-8") text = re.sub(r"\\\n[ \t]*", " ", text) - rules, variables, current, in_define = [], {}, None, False + rules, current, in_define = {}, None, False for line in text.splitlines(): if in_define: in_define = not line.startswith("endef") @@ -60,145 +70,156 @@ def _parse_makefile(): in_define, current = True, None continue if line.startswith("\t"): - if current is not None: - current[2].append(line.strip()) + words = line.split() + if current and "$(CC)" in words and "-c" not in words: + for target in current[0]: + rules[target] = current[1] continue stripped = line.split("#", 1)[0].strip() - if not stripped: + if not stripped or DIRECTIVE_RE.match(stripped): continue - assign = ASSIGN_RE.match(stripped) - if assign: - variables.setdefault(assign.group(1), []).append(assign.group(2)) + if ASSIGN_RE.match(stripped): current = None continue - if DIRECTIVE_RE.match(stripped): - continue rule = RULE_RE.match(stripped) if rule: prereqs = rule.group(2).split(";", 1)[0].split("|", 1)[0].split() - current = (rule.group(1).split(), prereqs, []) - rules.append(current) - return rules, variables - - -def _expand_words(word, variables, depth=0): - """Every word a $(VAR) can stand for, across all its definitions.""" - match = VAR_RE.fullmatch(word) - if not match or depth > 8: - return [word] - words = [] - for value in variables.get(match.group(1), []): - for part in value.split(): - words.extend(_expand_words(part, variables, depth + 1)) - return words - - -def _vk_stripping_vars(variables): - return {name for name, values in variables.items() - if any("filter-out" in v and "-DCOLI_VULKAN" in v for v in values)} - - -class _IncludeGraph: - """Quoted-#include reachability, reading each file once.""" - - def __init__(self): - self._files = {} - - def _scan(self, path): - if path not in self._files: - text = path.read_text(encoding="utf-8", errors="replace") - includes = [] - for name in INCLUDE_RE.findall(text): - for base in (path.parent, C_DIR): - candidate = (base / name).resolve() - if candidate.is_file(): - includes.append(candidate) - break - self._files[path] = (bool(VK_HOOK_RE.search(text)), includes) - return self._files[path] - - def reaches_vk_hook(self, source): - """True when `source` or anything it quote-includes has a hook.""" - seen, stack = set(), [source.resolve()] - while stack: - path = stack.pop() - if path in seen or not path.is_file(): - continue - seen.add(path) - hooked, includes = self._scan(path) - if hooked: - return True - stack.extend(includes) - return False - - -def _rules_needing_vk_obj(): - """[(target, has_prerequisite, has_link_argument)] for every rule whose - link recipe compiles a translation unit that reaches a COLI_VULKAN hook.""" - rules, variables = _parse_makefile() - stripping = _vk_stripping_vars(variables) - graph = _IncludeGraph() - found = [] - for targets, prereqs, recipe in rules: - for line in recipe: - words = line.split() - if "$(CC)" not in words or "-c" in words or "-UCOLI_VULKAN" in words: - continue - if stripping & {m.group(1) for m in map(VAR_RE.fullmatch, words) if m}: - continue - sources = [] - for word in words: - if word == "$<": - sources.extend(prereqs[:1]) - elif word == "$^": - sources.extend(prereqs) - else: - sources.extend(_expand_words(word, variables)) - if any(s.endswith(".c") and graph.reaches_vk_hook(C_DIR / s) - for s in sources): - found.append((" ".join(targets), "$(VK_OBJ)" in prereqs, - "$(VK_OBJ)" in words or "$^" in words)) - return found + current = (rule.group(1).split(), prereqs) + return rules -class MakefileVkObjTest(unittest.TestCase): - def test_the_scan_is_derived_and_not_vacuous(self): - """A parse that matches nothing would pass the check below silently. +def _backend_api(): + text = (C_DIR / "backend_vulkan.h").read_text(encoding="utf-8") + return set(re.findall(r"\bcoli_vk_\w+", text)) - Cross-check against the engines themselves: every NAME.c carrying a - COLI_VULKAN hook that has a `NAME$(EXE):` rule must be found by the - same scan, since that rule compiles it directly. + +def _make(*args): + return subprocess.run([MAKE, "--no-print-directory", *args], cwd=C_DIR, + capture_output=True, text=True, errors="replace", + timeout=600) + + +def _unresolved(words, api): + """Backend functions the link line's sources call without defining them, + or None when this host cannot preprocess them (and so cannot build them + either; the platforms that can are where the line gets checked).""" + cc, flags, sources, i = words[0], [], [], 1 + while i < len(words): + w = words[i] + if w in SKIP_PAIR: + i += 2 + continue + if w.endswith(INPUT_SUFFIXES): + if w.endswith(".c"): + sources.append(w) + elif w != "-c" and not SKIP_WORD.match(w): + flags.append(w) + i += 1 + called, defined = set(), set() + for source in sources: + r = subprocess.run([cc, "-E", *flags, source], cwd=C_DIR, + capture_output=True, text=True, errors="replace", + timeout=300) + if r.returncode != 0: + return None + # Keep only this tree's own code: system headers cannot call the + # backend, and backend_vulkan.h only declares it. + kept, keep, own = [], True, {} + for line in r.stdout.splitlines(): + marker = LINEMARKER_RE.match(line) + if marker: + path = marker.group(1) + if path not in own: + resolved = (C_DIR / path).resolve() + own[path] = (not path.startswith("<") + and resolved.is_relative_to(C_DIR) + and resolved.name != "backend_vulkan.h") + keep = own[path] + elif keep: + kept.append(line) + code = STRING_RE.sub('""', "\n".join(kept)) + called |= set(NAME_RE.findall(code)) & api + defined |= set(DEF_RE.findall(code)) & api + return called - defined + + +@unittest.skipUnless(MAKE, "make is required") +class MakefileVkObjTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + # A dry run still rewrites .build-config at parse time; put it back so + # running this test does not make the next real build relink. + stamp = C_DIR / ".build-config" + saved = (stamp.read_bytes(), stamp.stat()) if stamp.exists() else None + try: + probe = _make("--eval", "vk-obj-probe: ; @echo '$(CC)|$(EXE)'", + "vk-obj-probe", "VK=1") + cc, exe = probe.stdout.strip().split("|") + cls.cc = cc.split()[0] + if not shutil.which(cls.cc): + raise unittest.SkipTest(f"{cls.cc} is required") + cls.rules = {t.replace("$(EXE)", exe): p + for t, p in _link_rules().items() + if "$(" not in t.replace("$(EXE)", "") and "%" not in t} + dry = _make("-Bnk", "VK=1", *sorted(cls.rules)) + finally: + if saved is None: + stamp.unlink(missing_ok=True) + else: + stamp.write_bytes(saved[0]) + os.utime(stamp, ns=(saved[1].st_atime_ns, saved[1].st_mtime_ns)) + cls.links = {} + for line in dry.stdout.splitlines(): + words = line.split() + if (words and words[0] == cls.cc and "-c" not in words + and "-o" in words): + cls.links[words[words.index("-o") + 1]] = words + api = _backend_api() + with ThreadPoolExecutor(max_workers=os.cpu_count() or 4) as pool: + results = dict(zip(cls.links, pool.map( + lambda words: _unresolved(words, api), cls.links.values()))) + cls.needs = {t: names for t, names in results.items() if names} + cls.unchecked = sorted(t for t, names in results.items() if names is None) + + def test_the_scan_is_not_vacuous(self): + """A broken dry run or preprocess would pass the check below silently. + + Cross-check against the engines: a NAME.c that calls a backend function + and has a `NAME$(EXE):` rule compiles that call directly, so the scan + must report the rule as calling the backend. """ - hooked = sorted(p.stem for p in C_DIR.glob("*.c") - if VK_HOOK_RE.search(p.read_text(encoding="utf-8", - errors="replace"))) - self.assertTrue(hooked, "no .c file under c/ has a COLI_VULKAN " - "conditional -- the hook syntax changed and " - "this file now checks nothing") - rules, _ = _parse_makefile() - engine_rules = {name for name in hooked - if any(f"{name}$(EXE)" in t for t, _, _ in rules)} - self.assertTrue(engine_rules, f"no `NAME$(EXE):` rule for {hooked}") - found = {target for target, _, _ in _rules_needing_vk_obj()} - for name in sorted(engine_rules): - self.assertIn(f"{name}$(EXE)", found, - f"the scan missed {name}$(EXE), which compiles " - f"{name}.c itself -- the recipe parse is broken") - self.assertTrue(any(t.startswith("tests/") for t in found), - "the scan found no test rule including an engine") - - def test_every_rule_compiling_a_hooked_engine_links_vk_obj(self): + api = _backend_api() + self.assertTrue(api, "backend_vulkan.h declares no coli_vk_*") + self.assertTrue(self.links, "make -Bn VK=1 printed no link line") + call = re.compile(r"\b(%s)\s*\(" % "|".join(sorted(api))) + engines = sorted( + t for t in self.links + if "/" not in t and (C_DIR / (t.split(".")[0] + ".c")).is_file() + and call.search((C_DIR / (t.split(".")[0] + ".c")).read_text( + encoding="utf-8", errors="replace"))) + self.assertTrue(engines, "no engine source calls the Vulkan backend") + for target in engines: + self.assertNotIn(target, self.unchecked, + f"{target} could not be preprocessed here") + self.assertIn(target, self.needs, + f"{target} calls the backend in its source, yet the " + f"scan found no call -- the dry run or preprocess " + f"is broken") + self.assertTrue(any(t.startswith("tests/") for t in self.needs), + "the scan found no test that calls the backend") + + def test_every_link_that_calls_vulkan_links_vk_obj(self): missing = [] - for target, has_prereq, has_link in _rules_needing_vk_obj(): - if not has_prereq: + for target, names in sorted(self.needs.items()): + example = sorted(names)[0] + if "backend_vulkan.o" not in self.links[target]: + missing.append(f"{target}: not on the link line (calls {example})") + if "$(VK_OBJ)" not in self.rules.get(target, []): missing.append(f"{target}: $(VK_OBJ) not a prerequisite") - if not has_link: - missing.append(f"{target}: $(VK_OBJ) not on the link line") self.assertFalse( missing, - "these rules compile a source with COLI_VULKAN hooks, so under " - "VK=1 they reference coli_vk_* and must link backend_vulkan.o:\n " - + "\n ".join(missing)) + "under VK=1 these links call coli_vk_* without defining it, so they " + "must link backend_vulkan.o:\n " + "\n ".join(missing)) if __name__ == "__main__": From 0b256d0d77a76e8cadca8cbccc11a2fdd0c7ae71 Mon Sep 17 00:00:00 2001 From: Kenneth-Javier Ortigoza Date: Sat, 26 Sep 2026 18:16:55 +0200 Subject: [PATCH 3/3] tests: read EXE without --eval, and see every link line The VK_OBJ test errored in setUpClass on the macOS runner: it read $(CC) and $(EXE) through `make --eval`, and /usr/bin/make on macOS is GNU Make 3.81, which predates --eval (3.82). Its stdout was empty; the rest of the suite passed (1479 run, 156 skipped, this one error). $(EXE) now comes from make's own database (`make -pn`), which 3.81 prints the same way, and the compiler is simply the first word of each link line. Two holes in reading `make -n` go with it: - recipe continuations are printed as written, so the five link commands that span lines (test_deepseek_v4 and the four segment/edge adapter tests) were never checked. They are folded first now. - a rule that writes a differently named file (fuzz-rans, bench-omp-grain, the dsv4 CUDA tests, glm53-metal-check) was skipped, because its output was not a rule name. Every printed command that compiles a .c into an output without -c is checked now, and such a rule is reported by its output name if it ever needs $(VK_OBJ). The scan covers 204 link lines on dev plus this branch (198 before). None of the added ones calls the backend, so the results against the linker are unchanged: - Linux (WSL Ubuntu 24.04, make 4.3): dev fails with the thirteen rules; this branch passes, and the 60 lines that call the backend are the 60 that link backend_vulkan.o; with #1338 merged only test_qwen36_slot_int8 is reported. About 22 s. - Windows (make 4.4.1): the same, with twelve on dev, since bench_idot cannot preprocess without ARCH=native. About 20 s. - dev + #1758 + this branch, conflicts resolved: passes together with #1758's test_makefile_deps and writes no .d file. Not verified locally: GNU Make 3.81 itself. The macOS runner is the check. --- c/tests/test_makefile_vk_obj.py | 38 ++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/c/tests/test_makefile_vk_obj.py b/c/tests/test_makefile_vk_obj.py index b59f7ff97..c94714ba3 100644 --- a/c/tests/test_makefile_vk_obj.py +++ b/c/tests/test_makefile_vk_obj.py @@ -152,13 +152,13 @@ def setUpClass(cls): stamp = C_DIR / ".build-config" saved = (stamp.read_bytes(), stamp.stat()) if stamp.exists() else None try: - probe = _make("--eval", "vk-obj-probe: ; @echo '$(CC)|$(EXE)'", - "vk-obj-probe", "VK=1") - cc, exe = probe.stdout.strip().split("|") - cls.cc = cc.split()[0] - if not shutil.which(cls.cc): - raise unittest.SkipTest(f"{cls.cc} is required") - cls.rules = {t.replace("$(EXE)", exe): p + # $(EXE) from make's own database. Not --eval: GNU Make 3.81, + # macOS's /usr/bin/make, predates it; -p is older than that. + database = _make("-pn", "VK=1", ".build-config").stdout + exe = re.search(r"(?m)^EXE :?= ?(\S*)", database) + if exe is None: + raise AssertionError("make -pn printed no EXE variable") + cls.rules = {t.replace("$(EXE)", exe.group(1)): p for t, p in _link_rules().items() if "$(" not in t.replace("$(EXE)", "") and "%" not in t} dry = _make("-Bnk", "VK=1", *sorted(cls.rules)) @@ -168,12 +168,21 @@ def setUpClass(cls): else: stamp.write_bytes(saved[0]) os.utime(stamp, ns=(saved[1].st_atime_ns, saved[1].st_mtime_ns)) - cls.links = {} - for line in dry.stdout.splitlines(): + # A link line is any printed command that compiles a .c into an output + # without -c -- also for rules that write a differently named file + # (fuzz-rans) -- and the compiler is whatever make put first on it. + # `make -n` prints recipe continuations as written, so fold them first. + lines = {} + for line in re.sub(r"\\\n[ \t]*", " ", dry.stdout).splitlines(): words = line.split() - if (words and words[0] == cls.cc and "-c" not in words - and "-o" in words): - cls.links[words[words.index("-o") + 1]] = words + if ("-o" in words and "-c" not in words + and words.index("-o") + 1 < len(words) + and any(w.endswith(".c") for w in words)): + lines[words[words.index("-o") + 1]] = words + compilers = {words[0] for words in lines.values()} + if lines and not any(shutil.which(c) for c in compilers): + raise unittest.SkipTest(f"{', '.join(sorted(compilers))} is required") + cls.links = {t: w for t, w in lines.items() if shutil.which(w[0])} api = _backend_api() with ThreadPoolExecutor(max_workers=os.cpu_count() or 4) as pool: results = dict(zip(cls.links, pool.map( @@ -214,7 +223,10 @@ def test_every_link_that_calls_vulkan_links_vk_obj(self): example = sorted(names)[0] if "backend_vulkan.o" not in self.links[target]: missing.append(f"{target}: not on the link line (calls {example})") - if "$(VK_OBJ)" not in self.rules.get(target, []): + if target not in self.rules: + missing.append(f"{target}: its rule is not named after it; " + f"list $(VK_OBJ) as that rule's prerequisite") + elif "$(VK_OBJ)" not in self.rules[target]: missing.append(f"{target}: $(VK_OBJ) not a prerequisite") self.assertFalse( missing,