ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_new_compound_has_mcdc_selftest.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Both-direction regression test for check_new_compound_has_mcdc.py.
4
5Split out of the detector itself to keep both files under the repository's
6per-file line cap (matching the ``doxy_audit.py`` / ``doxy_selftest.py``
7split). Exercises the REAL range-audit code path against a throwaway git
8repository, so a detector that quietly stopped matching cannot pass as clean:
9every fixture below asserts a real gap FIRES and a correctly covered or
10exempt form of the same shape stays SILENT.
11
12Imports the mechanism under test from ``check_new_compound_has_mcdc`` rather
13than re-implementing it, so there is exactly one definition of "this decision
14lacks MC/DC vectors" for both the detector and its own regression test.
15"""
16
17from __future__ import annotations
18
19import subprocess
20import sys
21import tempfile
22from pathlib import Path
23
24sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
25
26from check_new_compound_has_mcdc import (
27 _git,
28 _is_test_source_name,
29 _working_test_sources,
30 audit_range,
31 compound_decision_lines,
32 resolve_range,
33)
34from git_environment import isolated_git_environment, trusted_git_executable
35
36_ST_PRE_C = """\
37ra8_err_t ra8_pre_fn(int a, int b)
38{
39 if (a && b) {
40 return k_ra8_ok;
41 }
42 return k_ra8_err;
43}
44"""
45
46# The head revision of the pre-existing file: modified (a comment added) but
47# the decision line is byte-identical, so it must NOT be flagged as new.
48_ST_PRE_C_HEAD = """\
49ra8_err_t ra8_pre_fn(int a, int b)
50{
51 /* unrelated edit that touches the file but not the decision */
52 if (a && b) {
53 return k_ra8_ok;
54 }
55 return k_ra8_err;
56}
57"""
58
59_ST_NEWDEC_C = """\
60ra8_err_t ra8_new_fn(int a, int b)
61{
62 if (a && b) {
63 return k_ra8_ok;
64 }
65 return k_ra8_err;
66}
67"""
68
69_ST_APP_NEWDEC_C = """\
70ra8_err_t ra8_app_new_fn(int a, int b)
71{
72 if (a && b) {
73 return k_ra8_ok;
74 }
75 return k_ra8_err;
76}
77"""
78
79_ST_APP_COVERED_C = """\
80ra8_err_t ra8_app_covered_fn(int a, int b)
81{
82 if (a && b) {
83 return k_ra8_ok;
84 }
85 return k_ra8_err;
86}
87"""
88
89_ST_APP_COLOCATED_TEST_C = """\
90/**
91 * @test ra8_app_covered_fn_mcdc
92 *
93 * @par MC/DC:
94 * Decision: `if (a && b)` cites
95 * apps/shared_libs/covered/src/covered.c@ra8_app_covered_fn
96 * - Vector 1: a=1, b=1 -> true
97 * - Vector 2: a=0, b=1 -> false (varies a)
98 * - Vector 3: a=1, b=0 -> false (varies b)
99 */
100void test_mcdc_ra8_app_covered_fn(void) {}
101"""
102
103_ST_MOVE_BASE_C = """\
104ra8_err_t ra8_moved_fn(int a, int b)
105{
106 if (a && b) {
107 return k_ra8_ok;
108 }
109 return k_ra8_err;
110}
111"""
112
113_ST_MOVE_HEAD_C = """\
114ra8_err_t ra8_moved_fn(int left, int right)
115{
116 if (left &&
117 right) {
118 return k_ra8_ok;
119 }
120 return k_ra8_err;
121}
122"""
123
124_ST_MOVE_EDIT_BASE_C = """\
125ra8_err_t ra8_moved_edit_fn(int a, int b, int c)
126{
127 if (a && b) {
128 return k_ra8_ok;
129 }
130 return k_ra8_err;
131}
132"""
133
134_ST_MOVE_EDIT_HEAD_C = """\
135ra8_err_t ra8_moved_edit_fn(int a, int b, int c)
136{
137 if ((a && b) || c) {
138 return k_ra8_ok;
139 }
140 return k_ra8_err;
141}
142"""
143
144_ST_SUBSTITUTE_BASE_C = """\
145ra8_err_t ra8_substitute_fn(int a, int b)
146{
147 if (a && b) {
148 return k_ra8_ok;
149 }
150 return k_ra8_err;
151}
152"""
153
154_ST_SUBSTITUTE_HEAD_C = """\
155ra8_err_t ra8_substitute_fn(int c, int d)
156{
157 if (c || d) {
158 return k_ra8_ok;
159 }
160 return k_ra8_err;
161}
162"""
163
164_ST_SPLIT_BASE_C = """\
165ra8_err_t ra8_split_first(int a, int b)
166{
167 return (a && b) ? k_ra8_ok : k_ra8_err;
168}
169
170ra8_err_t ra8_split_second(int c, int d)
171{
172 return (c || d) ? k_ra8_ok : k_ra8_err;
173}
174"""
175
176_ST_SPLIT_HEAD_C = """\
177ra8_err_t ra8_split_first(int a, int b)
178{
179 return (a && b) ? k_ra8_ok : k_ra8_err;
180}
181"""
182
183_ST_SPLIT_EXTRACTED_C = """\
184ra8_err_t ra8_split_second(int left, int right)
185{
186 return (left || right) ? k_ra8_ok : k_ra8_err;
187}
188"""
189
190_ST_COVERED_C = """\
191ra8_err_t ra8_covered_fn(int c, int d)
192{
193 if (c && d) {
194 return k_ra8_ok;
195 }
196 return k_ra8_err;
197}
198"""
199
200_ST_TEST_COVERED_C = """\
201/**
202 * @test ra8_covered_fn_mcdc
203 *
204 * @par MC/DC:
205 * Decision: `if (c && d)` cites libs/covered.c@ra8_covered_fn
206 * - Vector 1: c=1, d=1 -> true
207 * - Vector 2: c=0, d=1 -> false (varies c)
208 * - Vector 3: c=1, d=0 -> false (varies d)
209 */
210void test_mcdc_ra8_covered_fn(void) {}
211"""
212
213_ST_INDEX_BASE_C = """\
214ra8_err_t ra8_staged_fn(int a, int b)
215{
216 if (a) {
217 return k_ra8_ok;
218 }
219 return k_ra8_err;
220}
221"""
222
223_ST_INDEX_HEAD_C = """\
224ra8_err_t ra8_staged_fn(int a, int b)
225{
226 if (a && b) {
227 return k_ra8_ok;
228 }
229 return k_ra8_err;
230}
231"""
232
233_ST_INDEX_TEST_C = """\
234/**
235 * @test ra8_staged_fn_mcdc
236 *
237 * @par MC/DC:
238 * Decision: `if (a && b)` cites libs/staged.c@ra8_staged_fn
239 * - Vector 1: a=1, b=1 -> true
240 * - Vector 2: a=0, b=1 -> false (varies a)
241 * - Vector 3: a=1, b=0 -> false (varies b)
242 */
243void test_mcdc_ra8_staged_fn(void) {}
244"""
245
246_ST_SOUP_C = """\
247int soup_fn(int e, int f)
248{
249 if (e && f) {
250 return 1;
251 }
252 return 0;
253}
254"""
255
256# A NOLINTNEXTLINE marker mid-parameter-list, the exact shape that made
257# enclosing_function() return "NOLINTNEXTLINE" instead of the real function
258# name (its own parenthesised rule name satisfied the walk's "(" stop
259# condition before the walk reached the true signature). Uncovered and not
260# cited anywhere: must fire, attributed to the real function.
261_ST_NOLINT_SIG_C = """\
262ra8_err_t ra8_nolint_sig_fn(int g,
263 // NOLINTNEXTLINE(readability-non-const-parameter)
264 int h)
265{
266 if (g && h) {
267 return k_ra8_ok;
268 }
269 return k_ra8_err;
270}
271"""
272
273# Same shape, but cited under the REAL function name. If enclosing_function()
274# regressed to reading "NOLINTNEXTLINE" as the name, this citation (which
275# names the true function) would not match and the decision would wrongly
276# fire despite being covered: must stay quiet.
277_ST_NOLINT_SIG_COVERED_C = """\
278ra8_err_t ra8_nolint_sig_covered_fn(int i,
279 // NOLINTNEXTLINE(readability-non-const-parameter)
280 int j)
281{
282 if (i && j) {
283 return k_ra8_ok;
284 }
285 return k_ra8_err;
286}
287"""
288
289#: Sentinel a firing fixture line carries, so the expectation is read off the
290#: fixture itself. Asserting the exact LINE NUMBERS that fire -- rather than a
291#: count -- also proves the lexical view preserves source line numbering: a
292#: masking bug that swallowed a newline could not pass as clean.
293_ST_LEXICAL_MARKER = "ST-DECISION"
294
295#: Every construct that must NEVER read as a compound decision: multi-line
296#: Doxygen prose (including an `@code` span), literals holding comment
297#: delimiters, escaped quotes, a backslash-spliced line comment, a spliced
298#: `#define`, and a `#if` guard.
299_ST_LEXICAL_QUIET_C = """\
300/**
301 * @brief Multi-line Doxygen prose is not code.
302 *
303 * @details The interior lines of this block are exactly what a line-local
304 * scrub cannot see: a decision quoted as `(a == 1) && (b == 2)`, SEC1
305 * notation of the form ``0x04 || X(32) || Y(32)``, and a signature spelled
306 * ``raw r||s``.
307 *
308 * @par MC/DC:
309 * Decision: `if (a)` (1 condition, no compound `&&`/`||`).
310 *
311 * @code
312 * if (example_a && example_b) {
313 * return 0;
314 * }
315 * @endcode
316 */
317static int st_quiet_fn(int a, int b)
318{
319 /* single-line block comment holding a && b */
320 const char *plain = "a && b";
321 const char *fake_open = "/* not a comment */ || still a string";
322 const char *escaped = "he said \\" && \\" and meant it";
323 const char apostrophe = '\\'';
324 // spliced line comment whose continuation still holds \\
325 the operators && and || on the next source line
326 (void)plain;
327 (void)fake_open;
328 (void)escaped;
329 (void)apostrophe;
330 return a + b;
331}
332
333#define ST_QUIET_MACRO(a, b) \\
334 ((a) && (b))
335
336#if defined(ST_QUIET_ONE) || defined(ST_QUIET_TWO)
337static int st_quiet_directive(void) { return 1; }
338#endif
339"""
340
341#: Every construct that MUST read as a compound decision. Each such line
342#: carries `_ST_LEXICAL_MARKER`; no other line may. The unterminated-looking
343#: `"/*"` literal is the over-blanking probe: a lexer that let it open a
344#: comment would silence both decisions below it.
345_ST_LEXICAL_FIRES_C = """\
346static int st_fire_fn(int a, int b)
347{
348 const char *opener = "/*";
349 (void)opener;
350 if (a && b) { /* ST-DECISION -- a literal must not open a comment */
351 return 1;
352 }
353 /* a block comment ending mid-line: || */ if (a || b) { /* ST-DECISION */
354 return 2;
355 }
356 return 0;
357}
358"""
359
360_ST_TEST_NOLINT_SIG_COVERED_C = """\
361/**
362 * @test ra8_nolint_sig_covered_fn_mcdc
363 *
364 * @par MC/DC:
365 * Decision: `if (i && j)` cites
366 * libs/nolint_sig_covered.c@ra8_nolint_sig_covered_fn
367 * - Vector 1: i=1, j=1 -> true
368 * - Vector 2: i=0, j=1 -> false (varies i)
369 * - Vector 3: i=1, j=0 -> false (varies j)
370 */
371void test_mcdc_ra8_nolint_sig_covered_fn(void) {}
372"""
373
374
375def _st_git(repo: Path, *args: str) -> None:
376 """Run a git command inside the self-test fixture repository."""
377 subprocess.run( # noqa: S603 # trusted: fixed git argv, temp repo
378 [
379 trusted_git_executable(),
380 "-C",
381 str(repo),
382 "-c",
383 "user.email=selftest@localhost",
384 "-c",
385 "user.name=selftest",
386 *args,
387 ],
388 check=True,
389 capture_output=True,
390 text=True,
391 )
392
393
394def _st_write(repo: Path, rel: str, body: str) -> None:
395 """Write ``body`` to ``rel`` under ``repo``, creating parent dirs."""
396 dst = repo / rel
397 dst.parent.mkdir(parents=True, exist_ok=True)
398 dst.write_text(body, encoding="utf-8")
399
400
401def _st_build_fixture(repo: Path) -> tuple[str, str]:
402 """Build a two-commit fixture and return the ``(base, head)`` SHAs.
403
404 The base commit holds a pre-existing decision; the head commit adds a
405 genuinely new uncovered decision (must fire), a new decision that carries
406 vectors (must stay quiet), a SOUP decision (excluded), and a cosmetic edit
407 to the pre-existing file (unchanged decision must stay quiet).
408 """
409 repo.mkdir(parents=True, exist_ok=True)
410 _st_git(repo, "init", "--quiet")
411 _st_write(repo, "libs/pre.c", _ST_PRE_C)
412 _st_write(repo, "apps/shared_libs/moved/src/original.c", _ST_MOVE_BASE_C)
413 _st_write(repo, "apps/shared_libs/moved_edit/src/original.c", _ST_MOVE_EDIT_BASE_C)
414 _st_write(repo, "apps/shared_libs/substitute/src/predicate.c", _ST_SUBSTITUTE_BASE_C)
415 _st_write(repo, "apps/shared_libs/split/src/combined.c", _ST_SPLIT_BASE_C)
416 _st_write(repo, "tests/.keep", "")
417 _st_git(repo, "add", "-A")
418 _st_git(repo, "commit", "--quiet", "--no-verify", "-m", "base")
419 base = _git("-C", str(repo), "rev-parse", "HEAD").strip()
420
421 _st_write(repo, "libs/pre.c", _ST_PRE_C_HEAD)
422 _st_write(repo, "libs/newdec.c", _ST_NEWDEC_C)
423 _st_write(repo, "apps/shared_libs/demo/src/newdec.c", _ST_APP_NEWDEC_C)
424 _st_write(repo, "apps/shared_libs/covered/src/covered.c", _ST_APP_COVERED_C)
425 _st_write(
426 repo,
427 "apps/shared_libs/covered/tests/src/test_covered.c",
428 _ST_APP_COLOCATED_TEST_C,
429 )
430 _st_write(repo, "libs/covered.c", _ST_COVERED_C)
431 _st_write(repo, "tests/test_covered.cpp", _ST_TEST_COVERED_C)
432 _st_write(repo, "libs/third_party/soup.c", _ST_SOUP_C)
433 _st_write(repo, "apps/shared_libs/third_party/soup/source.c", _ST_SOUP_C)
434 _st_write(repo, "libs/nolint_sig.c", _ST_NOLINT_SIG_C)
435 _st_write(repo, "libs/nolint_sig_covered.c", _ST_NOLINT_SIG_COVERED_C)
436 _st_write(repo, "tests/test_nolint_sig_covered.cpp", _ST_TEST_NOLINT_SIG_COVERED_C)
437 _st_git(
438 repo,
439 "mv",
440 "apps/shared_libs/moved/src/original.c",
441 "apps/shared_libs/moved/src/relocated.c",
442 )
443 _st_write(repo, "apps/shared_libs/moved/src/relocated.c", _ST_MOVE_HEAD_C)
444 _st_git(
445 repo,
446 "mv",
447 "apps/shared_libs/moved_edit/src/original.c",
448 "apps/shared_libs/moved_edit/src/relocated.c",
449 )
450 _st_write(repo, "apps/shared_libs/moved_edit/src/relocated.c", _ST_MOVE_EDIT_HEAD_C)
451 _st_write(repo, "apps/shared_libs/substitute/src/predicate.c", _ST_SUBSTITUTE_HEAD_C)
452 _st_write(repo, "apps/shared_libs/split/src/combined.c", _ST_SPLIT_HEAD_C)
453 _st_write(repo, "apps/shared_libs/split/src/extracted.c", _ST_SPLIT_EXTRACTED_C)
454 _st_git(repo, "add", "-A")
455 _st_git(repo, "commit", "--quiet", "--no-verify", "-m", "head")
456 head = _git("-C", str(repo), "rev-parse", "HEAD").strip()
457 return base, head
458
459
460def _st_check_move_contract(found_paths: set[str]) -> list[str]:
461 """Check move/split/substitution and app-colocated citation behavior."""
462 failures: list[str] = []
463 if "apps/shared_libs/moved_edit/src/relocated.c" not in found_paths:
464 failures.append(" did NOT fire when a moved function gained a compound operator")
465 if "apps/shared_libs/moved/src/relocated.c" in found_paths:
466 failures.append(" fired on a moved/reformatted decision with no operator growth")
467 if "apps/shared_libs/split/src/extracted.c" in found_paths:
468 failures.append(" fired on a same-component function extraction")
469 if "apps/shared_libs/substitute/src/predicate.c" not in found_paths:
470 failures.append(" did NOT fire on a same-count structural predicate replacement")
471 if "apps/shared_libs/covered/src/covered.c" in found_paths:
472 failures.append(" ignored an app-colocated MC/DC citation")
473 return failures
474
475
476def _st_check(files: list[str], findings: list[tuple[str, int, str]]) -> list[str]:
477 """Assert the range audit fired on the right decision and nowhere else."""
478 found_paths = {p for p, _, _ in findings}
479 failures = _st_check_move_contract(found_paths)
480 if "libs/newdec.c" not in found_paths:
481 failures.append(" did NOT fire on libs/newdec.c (a new uncovered decision)")
482 if "apps/shared_libs/demo/src/newdec.c" not in found_paths:
483 failures.append(" did NOT fire on app-owned production code")
484 if "libs/covered.c" in found_paths:
485 failures.append(" fired on libs/covered.c (it already carries MC/DC vectors)")
486 if "libs/pre.c" in found_paths:
487 failures.append(" fired on libs/pre.c (a pre-existing, unchanged decision)")
488 if any("third_party" in p for p in found_paths):
489 failures.append(" fired inside libs/third_party/ (SOUP is exempt)")
490 if "libs/nolint_sig.c" not in found_paths:
491 failures.append(
492 " did NOT fire on libs/nolint_sig.c (uncovered decision behind a "
493 "NOLINTNEXTLINE-commented multi-line signature)"
494 )
495 if "libs/nolint_sig_covered.c" in found_paths:
496 failures.append(
497 " fired on libs/nolint_sig_covered.c (cited under its real function "
498 "name; enclosing_function() must skip the NOLINTNEXTLINE comment "
499 "line, not resolve it as the function name)"
500 )
501 if any("third_party" in path for path in files):
502 failures.append(" selected a third_party file (SOUP must be excluded)")
503 if len(findings) != 5: # noqa: PLR2004 -- fixture plants exactly 5 uncovered functions
504 failures.append(
505 f" expected exactly 5 finding(s), got {len(findings)}: {sorted(found_paths)}"
506 )
507 return failures
508
509
510def _st_check_lexical_view() -> list[str]:
511 """Assert the lexical view hides prose and literals but never real code.
512
513 Both directions, because either alone proves nothing: a view that blanked
514 the whole file would satisfy the quiet fixture while silently reporting no
515 debt anywhere, and a view that blanked nothing would satisfy a firing
516 fixture while counting every comment as a decision. The firing expectation
517 is the exact set of marked line numbers, which doubles as the non-vacuity
518 floor -- an empty result can never satisfy it.
519 """
520 failures: list[str] = []
521 quiet = compound_decision_lines(_ST_LEXICAL_QUIET_C)
522 if quiet:
523 detail = "; ".join(f"line {line}: {text}" for line, text in sorted(quiet))
524 failures.append(f" prose, a literal, or a directive read as a decision: {detail}")
525 expected = {
526 index
527 for index, line in enumerate(_ST_LEXICAL_FIRES_C.splitlines(), start=1)
528 if _ST_LEXICAL_MARKER in line
529 }
530 fired = {line for line, _text in compound_decision_lines(_ST_LEXICAL_FIRES_C)}
531 if fired != expected:
532 failures.append(
533 f" real decisions fired on lines {sorted(fired)}; expected {sorted(expected)}"
534 )
535 return failures
536
537
538def _st_check_test_scope() -> list[str]:
539 """Assert the test-source name rule, in both directions.
540
541 Must accept both orderings of the convention and both extensions, and must
542 still REFUSE an ordinary source file -- a rule that widened to every .c
543 would sweep production files into the citation index and make uncovered
544 decisions look covered.
545
546 Returns:
547 One message per misclassification; empty when the rule is correct.
548 """
549 failures: list[str] = [
550 f" {name} is not recognised as a test translation unit"
551 for name in ("test_x.c", "test_x.cpp", "x_test.c", "x_test.cpp")
552 if not _is_test_source_name(name)
553 ]
554 failures += [
555 f" {name} was wrongly recognised as a test translation unit"
556 for name in ("latest.c", "protest.cpp", "x_tester.c", "test_x.h", "mdl_net.c")
557 if _is_test_source_name(name)
558 ]
559 with tempfile.TemporaryDirectory() as td:
560 tests_dir = Path(td) / "tests"
561 (tests_dir / "support").mkdir(parents=True)
562 (tests_dir / "test_top.c").write_text("", encoding="utf-8")
563 (tests_dir / "support" / "thing_test.c").write_text("", encoding="utf-8")
564 (tests_dir / "support" / "helper.c").write_text("", encoding="utf-8")
565 found = {path.name for path in _working_test_sources(tests_dir)}
566 if found != {"test_top.c", "thing_test.c"}:
567 failures.append(f" the recursive enumeration returned {sorted(found)}")
568 return failures
569
570
571def _st_staged_result(repo: Path) -> int:
572 """Run the real staged-mode CLI against ``repo`` and return its status."""
573 checker = Path(__file__).with_name("check_new_compound_has_mcdc.py")
574 return subprocess.run( # noqa: S603 -- trusted interpreter/checker paths
575 [sys.executable, str(checker), "--staged"],
576 cwd=repo,
577 check=False,
578 capture_output=True,
579 text=True,
580 ).returncode
581
582
583def _st_check_index_citations() -> list[str]:
584 """Prove staged mode reads citations from the index in both directions."""
585 failures: list[str] = []
586 with tempfile.TemporaryDirectory() as td:
587 repo = Path(td) / "fixture"
588 repo.mkdir()
589 _st_git(repo, "init", "--quiet")
590 _st_write(repo, "libs/staged.c", _ST_INDEX_BASE_C)
591 _st_git(repo, "add", "-A")
592 _st_git(repo, "commit", "--quiet", "--no-verify", "-m", "base")
593 _st_write(repo, "libs/staged.c", _ST_INDEX_HEAD_C)
594 _st_git(repo, "add", "libs/staged.c")
595 _st_write(repo, "tests/test_staged.c", _ST_INDEX_TEST_C)
596 if _st_staged_result(repo) != 1:
597 failures.append(" an untracked citation satisfied the staged decision gate")
598 _st_git(repo, "add", "tests/test_staged.c")
599 if _st_staged_result(repo) != 0:
600 failures.append(" a staged citation did not satisfy the staged decision gate")
601 _st_write(repo, "tests/test_staged.c", "/* unstaged removal */\n")
602 if _st_staged_result(repo) != 0:
603 failures.append(" an unstaged citation removal changed the index verdict")
604 return failures
605
606
607def _run_selftest_body() -> int:
608 """Assert the detector fires on a new uncovered decision and stays quiet otherwise.
609
610 Exercises the REAL range-audit code path against a throwaway repository, so
611 a detector that quietly stopped matching cannot pass as clean. Both
612 directions are asserted: it must fire on platform and app-owned production
613 code and stay silent on the pre-existing decision, the vector-covered
614 decision, and both vendor-root SOUP decisions. ``_st_check_lexical_view()``
615 asserts the same pair for the lexical view the measurement reads through,
616 so comment prose can never be frozen into the ratchet baseline as debt.
617 """
618 with tempfile.TemporaryDirectory() as td:
619 repo = Path(td) / "fixture"
620 base, head = _st_build_fixture(repo)
621 rng = resolve_range(str(repo), f"{base}..{head}")
622 if rng is None:
623 print("check_new_compound_has_mcdc.py: --selftest FAILED", file=sys.stderr)
624 print(" a valid base..head range did not resolve", file=sys.stderr)
625 return 1
626 files, findings = audit_range(str(repo), *rng)
627 failures = _st_check(files, findings)
628 failures += _st_check_lexical_view()
629 failures += _st_check_test_scope()
630 failures += _st_check_index_citations()
631
632 if failures:
633 print("check_new_compound_has_mcdc.py: --selftest FAILED", file=sys.stderr)
634 print("\n".join(failures), file=sys.stderr)
635 return 1
636 print(
637 "check_new_compound_has_mcdc.py: --selftest OK "
638 "(fires on new platform/app and moved-with-growth decisions; silent on "
639 "moves, splits, alpha-renames, colocated vectors, and both "
640 "SOUP roots; test-source scope holds both ways; the lexical view hides "
641 "multi-line prose, literals, and spliced directives while every marked "
642 "real decision still fires)."
643 )
644 return 0
645
646
647def run_selftest() -> int:
648 """Run range and index fixtures without inheriting the caller's repo."""
649 with isolated_git_environment():
650 return _run_selftest_body()