3"""Both-direction regression test for check_new_compound_has_mcdc.py.
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.
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.
17from __future__
import annotations
22from pathlib
import Path
24sys.path.insert(0, str(Path(__file__).resolve().parents[1] /
"dev"))
26from check_new_compound_has_mcdc
import (
29 _working_test_sources,
31 compound_decision_lines,
34from git_environment
import isolated_git_environment, trusted_git_executable
37ra8_err_t ra8_pre_fn(int a, int b)
49ra8_err_t ra8_pre_fn(int a, int b)
51 /* unrelated edit that touches the file but not the decision */
60ra8_err_t ra8_new_fn(int a, int b)
69_ST_APP_NEWDEC_C =
"""\
70ra8_err_t ra8_app_new_fn(int a, int b)
79_ST_APP_COVERED_C =
"""\
80ra8_err_t ra8_app_covered_fn(int a, int b)
89_ST_APP_COLOCATED_TEST_C =
"""\
91 * @test ra8_app_covered_fn_mcdc
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)
100void test_mcdc_ra8_app_covered_fn(void) {}
103_ST_MOVE_BASE_C =
"""\
104ra8_err_t ra8_moved_fn(int a, int b)
113_ST_MOVE_HEAD_C =
"""\
114ra8_err_t ra8_moved_fn(int left, int right)
124_ST_MOVE_EDIT_BASE_C =
"""\
125ra8_err_t ra8_moved_edit_fn(int a, int b, int c)
134_ST_MOVE_EDIT_HEAD_C =
"""\
135ra8_err_t ra8_moved_edit_fn(int a, int b, int c)
144_ST_SUBSTITUTE_BASE_C =
"""\
145ra8_err_t ra8_substitute_fn(int a, int b)
154_ST_SUBSTITUTE_HEAD_C =
"""\
155ra8_err_t ra8_substitute_fn(int c, int d)
164_ST_SPLIT_BASE_C =
"""\
165ra8_err_t ra8_split_first(int a, int b)
167 return (a && b) ? k_ra8_ok : k_ra8_err;
170ra8_err_t ra8_split_second(int c, int d)
172 return (c || d) ? k_ra8_ok : k_ra8_err;
176_ST_SPLIT_HEAD_C =
"""\
177ra8_err_t ra8_split_first(int a, int b)
179 return (a && b) ? k_ra8_ok : k_ra8_err;
183_ST_SPLIT_EXTRACTED_C =
"""\
184ra8_err_t ra8_split_second(int left, int right)
186 return (left || right) ? k_ra8_ok : k_ra8_err;
191ra8_err_t ra8_covered_fn(int c, int d)
200_ST_TEST_COVERED_C =
"""\
202 * @test ra8_covered_fn_mcdc
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)
210void test_mcdc_ra8_covered_fn(void) {}
213_ST_INDEX_BASE_C =
"""\
214ra8_err_t ra8_staged_fn(int a, int b)
223_ST_INDEX_HEAD_C =
"""\
224ra8_err_t ra8_staged_fn(int a, int b)
233_ST_INDEX_TEST_C =
"""\
235 * @test ra8_staged_fn_mcdc
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)
243void test_mcdc_ra8_staged_fn(void) {}
247int soup_fn(int e, int f)
261_ST_NOLINT_SIG_C =
"""\
262ra8_err_t ra8_nolint_sig_fn(int g,
263 // NOLINTNEXTLINE(readability-non-const-parameter)
277_ST_NOLINT_SIG_COVERED_C =
"""\
278ra8_err_t ra8_nolint_sig_covered_fn(int i,
279 // NOLINTNEXTLINE(readability-non-const-parameter)
293_ST_LEXICAL_MARKER =
"ST-DECISION"
299_ST_LEXICAL_QUIET_C =
"""\
301 * @brief Multi-line Doxygen prose is not code.
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
309 * Decision: `if (a)` (1 condition, no compound `&&`/`||`).
312 * if (example_a && example_b) {
317static int st_quiet_fn(int a, int b)
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
333#define ST_QUIET_MACRO(a, b) \\
336#if defined(ST_QUIET_ONE) || defined(ST_QUIET_TWO)
337static int st_quiet_directive(void) { return 1; }
345_ST_LEXICAL_FIRES_C =
"""\
346static int st_fire_fn(int a, int b)
348 const char *opener = "/*";
350 if (a && b) { /* ST-DECISION -- a literal must not open a comment */
353 /* a block comment ending mid-line: || */ if (a || b) { /* ST-DECISION */
360_ST_TEST_NOLINT_SIG_COVERED_C =
"""\
362 * @test ra8_nolint_sig_covered_fn_mcdc
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)
371void test_mcdc_ra8_nolint_sig_covered_fn(void) {}
375def _st_git(repo: Path, *args: str) ->
None:
376 """Run a git command inside the self-test fixture repository."""
379 trusted_git_executable(),
383 "user.email=selftest@localhost",
385 "user.name=selftest",
394def _st_write(repo: Path, rel: str, body: str) ->
None:
395 """Write ``body`` to ``rel`` under ``repo``, creating parent dirs."""
397 dst.parent.mkdir(parents=
True, exist_ok=
True)
398 dst.write_text(body, encoding=
"utf-8")
401def _st_build_fixture(repo: Path) -> tuple[str, str]:
402 """Build a two-commit fixture and return the ``(base, head)`` SHAs.
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).
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()
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)
427 "apps/shared_libs/covered/tests/src/test_covered.c",
428 _ST_APP_COLOCATED_TEST_C,
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)
440 "apps/shared_libs/moved/src/original.c",
441 "apps/shared_libs/moved/src/relocated.c",
443 _st_write(repo,
"apps/shared_libs/moved/src/relocated.c", _ST_MOVE_HEAD_C)
447 "apps/shared_libs/moved_edit/src/original.c",
448 "apps/shared_libs/moved_edit/src/relocated.c",
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()
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")
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:
492 " did NOT fire on libs/nolint_sig.c (uncovered decision behind a "
493 "NOLINTNEXTLINE-commented multi-line signature)"
495 if "libs/nolint_sig_covered.c" in found_paths:
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)"
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:
505 f
" expected exactly 5 finding(s), got {len(findings)}: {sorted(found_paths)}"
510def _st_check_lexical_view() -> list[str]:
511 """Assert the lexical view hides prose and literals but never real code.
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.
520 failures: list[str] = []
521 quiet = compound_decision_lines(_ST_LEXICAL_QUIET_C)
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}")
527 for index, line
in enumerate(_ST_LEXICAL_FIRES_C.splitlines(), start=1)
528 if _ST_LEXICAL_MARKER
in line
530 fired = {line
for line, _text
in compound_decision_lines(_ST_LEXICAL_FIRES_C)}
531 if fired != expected:
533 f
" real decisions fired on lines {sorted(fired)}; expected {sorted(expected)}"
538def _st_check_test_scope() -> list[str]:
539 """Assert the test-source name rule, in both directions.
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.
547 One message per misclassification; empty when the rule is correct.
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)
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)
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)}")
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(
575 [sys.executable, str(checker),
"--staged"],
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"
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")
607def _run_selftest_body() -> int:
608 """Assert the detector fires on a new uncovered decision and stays quiet otherwise.
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.
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}")
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)
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()
633 print(
"check_new_compound_has_mcdc.py: --selftest FAILED", file=sys.stderr)
634 print(
"\n".join(failures), file=sys.stderr)
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)."
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()