fortrangoingonforty/armfortas / 648121e

Browse files

Add exact file and phase repro oracles

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
648121e729a8d7a5045bef87db75dea3612ec935
Parents
27bc208
Tree
927ddb9

6 changed files

StatusFile+-
M .docs/testing/testing02.md 6 1
M README.md 2 1
M test_programs/function_call.f90 1 1
M test_programs/io_rewind.f90 1 0
M tests/README.md 1 0
M tests/run_programs.rs 211 12
.docs/testing/testing02.mdmodified
@@ -33,6 +33,8 @@ Filesystem-side-effect oracles are also expanding beyond content checks:
3333
   families without hardcoding every byte of formatted output
3434
 - `FILE_RERUN_MODE` makes overwrite-vs-append intent explicit instead of
3535
   relying on a human to infer it from the source
36
+- `FILE_SET_EXACT` lets a test pin the full runtime side-effect surface,
37
+  not just a handful of named files
3638
 - `REPRO_CHECK: run_same_sandbox` makes repeated-run side effects explicit,
3739
   so append-vs-replace and stale-file bugs can be tested deliberately
3840
 
@@ -40,8 +42,11 @@ Phase triangulation is also getting a stronger policy mode:
4042
 
4143
 - `PHASE_TRIANGULATE: ... | clean` requires compile-only surfaces to leave
4244
   only their requested artifact in a private phase sandbox
45
+- `PHASE_TRIANGULATE: ... | repro` requires compile-only surfaces to stay
46
+  byte-identical across repeated materialization
4347
 - that lets a runtime-side-effecting test prove `--emit-ir`, `-S`, and `-c`
44
-  do not accidentally create the files that only linked execution should create
48
+  do not accidentally create the files that only linked execution should create,
49
+  while also keeping those compile-only outputs deterministic
4550
 
4651
 ## Goal
4752
 
README.mdmodified
@@ -197,9 +197,10 @@ source-embedded assertions such as:
197197
 - `! FILE_EXISTS:` / `! FILE_MISSING:` for explicit sandbox presence or absence
198198
 - `! FILE_LINE_COUNT:` for structural file-shape assertions
199199
 - `! FILE_RERUN_MODE:` for explicit overwrite vs append intent across reruns
200
+- `! FILE_SET_EXACT:` for exact runtime side-effect file sets
200201
 - `! REPRO_CHECK:` for per-test asm/object/run reproducibility
201202
 - `! OPT_EQ:` for explicit cross-opt invariants
202
-- `! PHASE_TRIANGULATE:` for same-opt IR/ASM/object availability and compile-cleanliness oracles
203
+- `! PHASE_TRIANGULATE:` for same-opt IR/ASM/object availability, compile-cleanliness, and compile-only reproducibility oracles
203204
 - `! IR_CHECK:` / `! IR_NOT:` for IR shape
204205
 
205206
 Those source comments are the canonical leaf-assertion language for the
test_programs/function_call.f90modified
@@ -1,5 +1,5 @@
11
 ! CHECK: 25
2
-! PHASE_TRIANGULATE: ir|asm|obj
2
+! PHASE_TRIANGULATE: ir|asm|obj|repro
33
 program test_func
44
     implicit none
55
     integer :: x, y
test_programs/io_rewind.f90modified
@@ -7,6 +7,7 @@
77
 ! FILE_MISSING: afs_rewind.tmp
88
 ! FILE_LINE_COUNT: afs_rewind.dat => 1
99
 ! FILE_RERUN_MODE: afs_rewind.dat => stable
10
+! FILE_SET_EXACT: afs_rewind.dat
1011
 program test_io_rewind
1112
     implicit none
1213
     integer :: x
tests/README.mdmodified
@@ -20,6 +20,7 @@ The canonical leaf-assertion language lives in source comments inside
2020
 - `! FILE_EXISTS:` / `! FILE_MISSING:`
2121
 - `! FILE_LINE_COUNT:`
2222
 - `! FILE_RERUN_MODE:`
23
+- `! FILE_SET_EXACT:`
2324
 - `! REPRO_CHECK:`
2425
 - `! OPT_EQ:`
2526
 - `! PHASE_TRIANGULATE:`
tests/run_programs.rsmodified
@@ -106,7 +106,7 @@
106106
 //! should use stable substrings that are intentionally expected
107107
 //! across the requested matrix.
108108
 //!
109
-//! ## FILE_CHECK / FILE_NOT / FILE_EXISTS / FILE_MISSING / FILE_LINE_COUNT / FILE_RERUN_MODE annotations
109
+//! ## FILE_CHECK / FILE_NOT / FILE_EXISTS / FILE_MISSING / FILE_LINE_COUNT / FILE_RERUN_MODE / FILE_SET_EXACT annotations
110110
 //!
111111
 //! Runtime tests can assert on files created inside their per-test
112112
 //! sandbox:
@@ -127,6 +127,9 @@
127127
 //!     program is executed twice in the same sandbox, the named file must
128128
 //!     either be byte-identical after both runs (`stable`) or grow by
129129
 //!     strict append (`append`).
130
+//!   * `! FILE_SET_EXACT: <relative-path>[,<relative-path>...]` — the
131
+//!     final sandbox file set must match exactly, with no extra side
132
+//!     effects beyond the listed relative paths.
130133
 //!
131134
 //! Paths are sandbox-relative on purpose. The harness runs each binary
132135
 //! in a private temp directory, so file assertions pin side effects
@@ -169,6 +172,7 @@
169172
 //!
170173
 //!   * `! PHASE_TRIANGULATE: ir|asm|obj`
171174
 //!   * `! PHASE_TRIANGULATE: ir|asm|obj|clean`
175
+//!   * `! PHASE_TRIANGULATE: ir|asm|obj|repro`
172176
 //!
173177
 //! The linked runtime path is the anchor. If the program runs correctly but
174178
 //! one of the requested extra surfaces fails to compile or produces an empty
@@ -180,6 +184,11 @@
180184
 //! only their explicit output artifact in a private phase sandbox. That lets a
181185
 //! runtime-side-effecting program assert that `--emit-ir`, `-S`, and `-c` do
182186
 //! not accidentally create the files that only linked execution should create.
187
+//!
188
+//! `repro` strengthens it in a different direction: each requested compile-only
189
+//! phase must produce byte-identical output across two independent compilations.
190
+//! This keeps pipeline oracles from checking only "exists" when what we really
191
+//! need is "exists, stays clean, and stays deterministic".
183192
 
184193
 use std::collections::BTreeMap;
185194
 use std::fs;
@@ -227,6 +236,11 @@ struct FileRerunModeCheck {
227236
     mode: FileRerunMode,
228237
 }
229238
 
239
+struct FileSetExactCheck {
240
+    line_num: usize,
241
+    rel_paths: Vec<String>,
242
+}
243
+
230244
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
231245
 enum ReproStage {
232246
     Asm,
@@ -256,6 +270,7 @@ enum PhaseSurface {
256270
     Asm,
257271
     Obj,
258272
     Clean,
273
+    Repro,
259274
 }
260275
 
261276
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -631,6 +646,62 @@ fn extract_file_rerun_mode_checks(
631646
     Ok(checks)
632647
 }
633648
 
649
+fn extract_file_set_exact(
650
+    source: &str,
651
+    filename: &str,
652
+) -> Result<Option<FileSetExactCheck>, String> {
653
+    let mut found: Option<FileSetExactCheck> = None;
654
+    for (i, line) in source.lines().enumerate() {
655
+        let line_num = i + 1;
656
+        let trimmed = line.trim();
657
+        let Some(rest) = trimmed.strip_prefix("! FILE_SET_EXACT:") else {
658
+            continue;
659
+        };
660
+        if let Some(existing) = &found {
661
+            return Err(format!(
662
+                "{}:{}: multiple FILE_SET_EXACT annotations are not allowed (another at line {})",
663
+                filename, line_num, existing.line_num
664
+            ));
665
+        }
666
+
667
+        let mut rel_paths = Vec::new();
668
+        for token in rest.trim().split(',') {
669
+            let rel_path = token.trim();
670
+            if rel_path.is_empty() {
671
+                return Err(format!(
672
+                    "{}:{}: FILE_SET_EXACT paths cannot be empty",
673
+                    filename, line_num
674
+                ));
675
+            }
676
+            if Path::new(rel_path).is_absolute() {
677
+                return Err(format!(
678
+                    "{}:{}: FILE_SET_EXACT paths must be relative, got '{}'",
679
+                    filename, line_num, rel_path
680
+                ));
681
+            }
682
+            if !rel_paths
683
+                .iter()
684
+                .any(|existing: &String| existing == rel_path)
685
+            {
686
+                rel_paths.push(rel_path.to_string());
687
+            }
688
+        }
689
+        if rel_paths.is_empty() {
690
+            return Err(format!(
691
+                "{}:{}: FILE_SET_EXACT needs at least one relative path",
692
+                filename, line_num
693
+            ));
694
+        }
695
+        rel_paths.sort();
696
+
697
+        found = Some(FileSetExactCheck {
698
+            line_num,
699
+            rel_paths,
700
+        });
701
+    }
702
+    Ok(found)
703
+}
704
+
634705
 fn extract_repro_checks(source: &str, filename: &str) -> Result<Vec<ReproStage>, String> {
635706
     let mut stages = Vec::new();
636707
     for (i, line) in source.lines().enumerate() {
@@ -753,9 +824,10 @@ fn extract_phase_triangulation(
753824
                 "asm" => PhaseSurface::Asm,
754825
                 "obj" => PhaseSurface::Obj,
755826
                 "clean" => PhaseSurface::Clean,
827
+                "repro" => PhaseSurface::Repro,
756828
                 other => {
757829
                     return Err(format!(
758
-                    "{}:{}: PHASE_TRIANGULATE surfaces must be ir, asm, obj, or clean; got '{}'",
830
+                    "{}:{}: PHASE_TRIANGULATE surfaces must be ir, asm, obj, clean, or repro; got '{}'",
759831
                     filename, line_num, other
760832
                 ))
761833
                 }
@@ -772,10 +844,10 @@ fn extract_phase_triangulation(
772844
         }
773845
         if surfaces
774846
             .iter()
775
-            .all(|surface| *surface == PhaseSurface::Clean)
847
+            .all(|surface| matches!(surface, PhaseSurface::Clean | PhaseSurface::Repro))
776848
         {
777849
             return Err(format!(
778
-                "{}:{}: PHASE_TRIANGULATE(clean) needs at least one of ir, asm, or obj",
850
+                "{}:{}: PHASE_TRIANGULATE policy-only annotations need at least one of ir, asm, or obj",
779851
                 filename, line_num
780852
             ));
781853
         }
@@ -1048,11 +1120,27 @@ fn match_file_rerun_mode_checks(
10481120
     Ok(())
10491121
 }
10501122
 
1123
+fn match_file_set_exact(
1124
+    check: &FileSetExactCheck,
1125
+    files: &BTreeMap<String, Vec<u8>>,
1126
+    filename: &str,
1127
+) -> Result<(), String> {
1128
+    let actual = files.keys().cloned().collect::<Vec<_>>();
1129
+    if actual != check.rel_paths {
1130
+        return Err(format!(
1131
+            "{}:{}: FILE_SET_EXACT failed: expected {:?}, got {:?}",
1132
+            filename, check.line_num, check.rel_paths, actual
1133
+        ));
1134
+    }
1135
+    Ok(())
1136
+}
1137
+
10511138
 fn collect_declared_runtime_paths(
10521139
     file_checks: &[FileCheck],
10531140
     file_presence_checks: &[FilePresenceCheck],
10541141
     file_line_count_checks: &[FileLineCountCheck],
10551142
     file_rerun_mode_checks: &[FileRerunModeCheck],
1143
+    file_set_exact: Option<&FileSetExactCheck>,
10561144
 ) -> BTreeMap<String, String> {
10571145
     let mut paths = BTreeMap::new();
10581146
     for check in file_checks {
@@ -1083,6 +1171,13 @@ fn collect_declared_runtime_paths(
10831171
             .entry(check.rel_path.clone())
10841172
             .or_insert_with(|| "FILE_RERUN_MODE".to_string());
10851173
     }
1174
+    if let Some(check) = file_set_exact {
1175
+        for rel_path in &check.rel_paths {
1176
+            paths
1177
+                .entry(rel_path.clone())
1178
+                .or_insert_with(|| "FILE_SET_EXACT".to_string());
1179
+        }
1180
+    }
10861181
     paths
10871182
 }
10881183
 
@@ -1144,6 +1239,7 @@ fn compile_phase_artifact(
11441239
         PhaseSurface::Asm => "asm",
11451240
         PhaseSurface::Obj => "obj",
11461241
         PhaseSurface::Clean => unreachable!("clean is a triangulation policy, not an artifact"),
1242
+        PhaseSurface::Repro => unreachable!("repro is a triangulation policy, not an artifact"),
11471243
     };
11481244
     let phase_sandbox = unique_temp_path(
11491245
         "phase_sandbox",
@@ -1164,6 +1260,7 @@ fn compile_phase_artifact(
11641260
         PhaseSurface::Asm => ("phase-output.s", &["-S"]),
11651261
         PhaseSurface::Obj => ("phase-output.o", &["-c"]),
11661262
         PhaseSurface::Clean => unreachable!(),
1263
+        PhaseSurface::Repro => unreachable!(),
11671264
     };
11681265
     let output_path = phase_sandbox.join(output_name);
11691266
 
@@ -1486,6 +1583,7 @@ fn render_phase_surfaces(surfaces: &[PhaseSurface]) -> String {
14861583
             PhaseSurface::Asm => "asm",
14871584
             PhaseSurface::Obj => "obj",
14881585
             PhaseSurface::Clean => "clean",
1586
+            PhaseSurface::Repro => "repro",
14891587
         })
14901588
         .collect::<Vec<_>>()
14911589
         .join("|")
@@ -1500,6 +1598,7 @@ fn run_phase_triangulation(
15001598
     declared_runtime_paths: &BTreeMap<String, String>,
15011599
 ) -> Result<(), String> {
15021600
     let require_clean = triangulation.surfaces.contains(&PhaseSurface::Clean);
1601
+    let require_repro = triangulation.surfaces.contains(&PhaseSurface::Repro);
15031602
     for surface in &triangulation.surfaces {
15041603
         match surface {
15051604
             PhaseSurface::Ir | PhaseSurface::Asm | PhaseSurface::Obj => {
@@ -1511,6 +1610,7 @@ fn run_phase_triangulation(
15111610
                         PhaseSurface::Asm => "assembly",
15121611
                         PhaseSurface::Obj => "object",
15131612
                         PhaseSurface::Clean => unreachable!(),
1613
+                        PhaseSurface::Repro => unreachable!(),
15141614
                     };
15151615
                     return Err(format!(
15161616
                         "{}:{}: PHASE_TRIANGULATE({}) produced empty {} output at {}",
@@ -1555,8 +1655,47 @@ fn run_phase_triangulation(
15551655
                         ));
15561656
                     }
15571657
                 }
1658
+                if require_repro {
1659
+                    let second_artifact =
1660
+                        compile_phase_artifact(compiler, source, opt_flag, *surface, filename)?;
1661
+                    if require_clean {
1662
+                        let second_file_keys: Vec<&str> = second_artifact
1663
+                            .sandbox_files
1664
+                            .keys()
1665
+                            .map(|key| key.as_str())
1666
+                            .collect();
1667
+                        if second_file_keys != vec![second_artifact.output_rel_path.as_str()] {
1668
+                            return Err(format!(
1669
+                                "{}:{}: PHASE_TRIANGULATE({}) failed at {}: repeated compile-only phase left unexpected files {:?} (expected only '{}')",
1670
+                                filename,
1671
+                                triangulation.line_num,
1672
+                                render_phase_surfaces(&triangulation.surfaces),
1673
+                                opt_flag,
1674
+                                second_file_keys,
1675
+                                second_artifact.output_rel_path,
1676
+                            ));
1677
+                        }
1678
+                    }
1679
+                    if artifact.output_bytes != second_artifact.output_bytes {
1680
+                        let surface_name = match surface {
1681
+                            PhaseSurface::Ir => "IR",
1682
+                            PhaseSurface::Asm => "assembly",
1683
+                            PhaseSurface::Obj => "object",
1684
+                            PhaseSurface::Clean => unreachable!(),
1685
+                            PhaseSurface::Repro => unreachable!(),
1686
+                        };
1687
+                        return Err(format!(
1688
+                            "{}:{}: PHASE_TRIANGULATE({}) failed at {}: {} output changed across repeated compile-only runs",
1689
+                            filename,
1690
+                            triangulation.line_num,
1691
+                            render_phase_surfaces(&triangulation.surfaces),
1692
+                            opt_flag,
1693
+                            surface_name,
1694
+                        ));
1695
+                    }
1696
+                }
15581697
             }
1559
-            PhaseSurface::Clean => {}
1698
+            PhaseSurface::Clean | PhaseSurface::Repro => {}
15601699
         }
15611700
     }
15621701
     Ok(())
@@ -1654,6 +1793,10 @@ fn run_test(compiler: &Path, source: &Path, opt_flag: &str) -> TestOutcome {
16541793
         Ok(checks) => checks,
16551794
         Err(e) => return TestOutcome::Fail(e),
16561795
     };
1796
+    let file_set_exact = match extract_file_set_exact(&source_text, filename) {
1797
+        Ok(check) => check,
1798
+        Err(e) => return TestOutcome::Fail(e),
1799
+    };
16571800
     let repro_checks = match extract_repro_checks(&source_text, filename) {
16581801
         Ok(checks) => checks,
16591802
         Err(e) => return TestOutcome::Fail(e),
@@ -1674,6 +1817,7 @@ fn run_test(compiler: &Path, source: &Path, opt_flag: &str) -> TestOutcome {
16741817
         && file_presence_checks.is_empty()
16751818
         && file_line_count_checks.is_empty()
16761819
         && file_rerun_mode_checks.is_empty()
1820
+        && file_set_exact.is_none()
16771821
         && repro_checks.is_empty()
16781822
         && opt_eq_rules.is_empty()
16791823
         && phase_triangulation.is_none()
@@ -1685,7 +1829,7 @@ fn run_test(compiler: &Path, source: &Path, opt_flag: &str) -> TestOutcome {
16851829
         // Programs with no runtime or shape assertions, no XFAIL marker,
16861830
         // and no ERROR marker are mis-configured tests, not test failures.
16871831
         return TestOutcome::Fail(format!(
1688
-            "{}: no CHECK / STDERR_CHECK / EXIT_CODE / IR_CHECK / ASM_CHECK / FILE_CHECK / FILE_EXISTS / FILE_MISSING / FILE_LINE_COUNT / FILE_RERUN_MODE / REPRO_CHECK / XFAIL / ERROR_EXPECTED / ERROR_SPAN annotations",
1832
+            "{}: no CHECK / STDERR_CHECK / EXIT_CODE / IR_CHECK / ASM_CHECK / FILE_CHECK / FILE_EXISTS / FILE_MISSING / FILE_LINE_COUNT / FILE_RERUN_MODE / FILE_SET_EXACT / REPRO_CHECK / XFAIL / ERROR_EXPECTED / ERROR_SPAN annotations",
16891833
             filename,
16901834
         ));
16911835
     }
@@ -1823,6 +1967,13 @@ fn run_test(compiler: &Path, source: &Path, opt_flag: &str) -> TestOutcome {
18231967
             let _ = fs::remove_dir_all(&sandbox);
18241968
             return Err(e);
18251969
         }
1970
+        if let Some(check) = &file_set_exact {
1971
+            if let Err(e) = match_file_set_exact(check, &snapshot.files, &label) {
1972
+                let _ = fs::remove_file(&binary);
1973
+                let _ = fs::remove_dir_all(&sandbox);
1974
+                return Err(e);
1975
+            }
1976
+        }
18261977
         if !file_rerun_mode_checks.is_empty() {
18271978
             let second = run_binary_in_sandbox(&binary, &sandbox, filename)?;
18281979
             if snapshot.exit_code != second.exit_code {
@@ -1987,6 +2138,7 @@ fn run_test(compiler: &Path, source: &Path, opt_flag: &str) -> TestOutcome {
19872138
                 &file_presence_checks,
19882139
                 &file_line_count_checks,
19892140
                 &file_rerun_mode_checks,
2141
+                file_set_exact.as_ref(),
19902142
             );
19912143
             run_phase_triangulation(
19922144
                 compiler,
@@ -2251,6 +2403,14 @@ fn extract_file_rerun_mode_checks_accepts_stable_and_append() {
22512403
     assert_eq!(checks[1].mode, FileRerunMode::Append);
22522404
 }
22532405
 
2406
+#[test]
2407
+fn extract_file_set_exact_accepts_relative_paths() {
2408
+    let check = extract_file_set_exact("! FILE_SET_EXACT: out.txt, log.txt\n", "inline.f90")
2409
+        .unwrap()
2410
+        .unwrap();
2411
+    assert_eq!(check.rel_paths, vec!["log.txt", "out.txt"]);
2412
+}
2413
+
22542414
 #[test]
22552415
 fn file_rerun_mode_matcher_accepts_strict_append_growth() {
22562416
     let checks = vec![FileRerunModeCheck {
@@ -2313,8 +2473,8 @@ fn extract_opt_eq_rules_rejects_unknown_component() {
23132473
 }
23142474
 
23152475
 #[test]
2316
-fn extract_phase_triangulation_accepts_ir_asm_obj_and_clean() {
2317
-    let source = "! PHASE_TRIANGULATE: ir|asm|obj|clean\n";
2476
+fn extract_phase_triangulation_accepts_ir_asm_obj_clean_and_repro() {
2477
+    let source = "! PHASE_TRIANGULATE: ir|asm|obj|clean|repro\n";
23182478
     let rule = extract_phase_triangulation(source, "inline.f90")
23192479
         .unwrap()
23202480
         .unwrap();
@@ -2324,7 +2484,8 @@ fn extract_phase_triangulation_accepts_ir_asm_obj_and_clean() {
23242484
             PhaseSurface::Ir,
23252485
             PhaseSurface::Asm,
23262486
             PhaseSurface::Obj,
2327
-            PhaseSurface::Clean
2487
+            PhaseSurface::Clean,
2488
+            PhaseSurface::Repro
23282489
         ]
23292490
     );
23302491
 }
@@ -2333,14 +2494,14 @@ fn extract_phase_triangulation_accepts_ir_asm_obj_and_clean() {
23332494
 fn extract_phase_triangulation_rejects_unknown_surface() {
23342495
     let source = "! PHASE_TRIANGULATE: run\n";
23352496
     let err = extract_phase_triangulation(source, "inline.f90").unwrap_err();
2336
-    assert!(err.contains("ir, asm, obj, or clean"));
2497
+    assert!(err.contains("ir, asm, obj, clean, or repro"));
23372498
 }
23382499
 
23392500
 #[test]
23402501
 fn extract_phase_triangulation_rejects_clean_only() {
2341
-    let source = "! PHASE_TRIANGULATE: clean\n";
2502
+    let source = "! PHASE_TRIANGULATE: clean|repro\n";
23422503
     let err = extract_phase_triangulation(source, "inline.f90").unwrap_err();
2343
-    assert!(err.contains("needs at least one of ir, asm, or obj"));
2504
+    assert!(err.contains("policy-only annotations"));
23442505
 }
23452506
 
23462507
 #[test]
@@ -2452,6 +2613,25 @@ fn file_presence_checks_allow_rewind_side_effects() {
24522613
     }
24532614
 }
24542615
 
2616
+#[test]
2617
+fn file_set_exact_allows_rewind_single_output() {
2618
+    let compiler = find_compiler();
2619
+    let test_dir = find_test_programs();
2620
+    let source = test_dir.join("io_rewind.f90");
2621
+    assert!(
2622
+        source.exists(),
2623
+        "io_rewind.f90 missing — needed for FILE_SET_EXACT coverage"
2624
+    );
2625
+
2626
+    match run_test(&compiler, &source, "-O0") {
2627
+        TestOutcome::Pass => {}
2628
+        other => panic!(
2629
+            "io_rewind.f90 should pass with FILE_SET_EXACT coverage, got {:?}",
2630
+            other
2631
+        ),
2632
+    }
2633
+}
2634
+
24552635
 #[test]
24562636
 fn file_rerun_mode_append_fixture_tracks_current_compiler_gap() {
24572637
     let compiler = find_compiler();
@@ -2547,6 +2727,25 @@ fn phase_triangulation_allows_function_call_pipeline_surfaces() {
25472727
     }
25482728
 }
25492729
 
2730
+#[test]
2731
+fn phase_triangulation_repro_keeps_function_call_compile_surfaces_stable() {
2732
+    let compiler = find_compiler();
2733
+    let test_dir = find_test_programs();
2734
+    let source = test_dir.join("function_call.f90");
2735
+    assert!(
2736
+        source.exists(),
2737
+        "function_call.f90 missing — needed for PHASE_TRIANGULATE(repro) coverage"
2738
+    );
2739
+
2740
+    match run_test(&compiler, &source, "-O0") {
2741
+        TestOutcome::Pass => {}
2742
+        other => panic!(
2743
+            "function_call.f90 should pass with PHASE_TRIANGULATE(repro) coverage, got {:?}",
2744
+            other
2745
+        ),
2746
+    }
2747
+}
2748
+
25502749
 #[test]
25512750
 fn phase_triangulation_clean_keeps_compile_phases_free_of_runtime_files() {
25522751
     let compiler = find_compiler();