test_ci_select: isolate from a real cmake-build dir and close every file it reads (#3943)
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
index 3946174..6f8be2f 100644
--- a/test/hil/test/test_ci_select.py
+++ b/test/hil/test/test_ci_select.py
@@ -30,6 +30,11 @@
 from helper.hil_util import device_tests, dual_tests
 
 
+def _read(path):
+    with open(path) as f:
+        return f.read()
+
+
 def real_rosters():
     """The actual rig rosters, for regression tests that need real-world data
     (a specific board/family/only-list) rather than the synthetic ROSTER above."""
@@ -850,7 +855,7 @@
     def test_no_board_name_is_in_two_rosters(self):
         seen = {}
         for name in ('tinyusb.json', 'hfp.json'):
-            cfg = json.load(open(os.path.join(REPO, 'test/hil', name)))
+            cfg = json.loads(_read(os.path.join(REPO, 'test/hil', name)))
             for b in cfg['boards']:
                 if b['name'] in seen:
                     self.assertEqual(
@@ -1143,8 +1148,7 @@
         return out
 
     def test_docstring_table_matches_the_spec(self):
-        spec = open(os.path.join(
-            REPO, 'docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md')).read()
+        spec = _read(os.path.join(REPO, 'docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md'))
         doc, spec_rows = self._rows(ci_select.__doc__), self._rows(spec)
         self.assertTrue(spec_rows, 'no rule table found in the spec')
         self.assertEqual([r[0] for r in doc], [r[0] for r in spec_rows],
@@ -1154,7 +1158,7 @@
 
     def test_every_documented_rule_has_a_branch(self):
         import re as _re
-        src = open(os.path.join(REPO, 'tools/ci_select.py')).read()
+        src = _read(os.path.join(REPO, 'tools/ci_select.py'))
         marked = set()
         # handles `# rule 6`, `# rules 1, 1b` and `# rules 8-10`
         for m in _re.finditer(r'#\s*rules?\s+([0-9a-z, -]+)', src):
@@ -2156,7 +2160,11 @@
             calls.append(cmd)
             return types.SimpleNamespace(returncode=0)
 
-        with mock.patch.object(self.build, 'run_cmd', fake_run):
+        # the checkout may hold a real cmake-build/cmake-build-espressif_s3_devkitc from HIL work
+        real_isdir = os.path.isdir
+        no_build_dir = lambda p: False if str(p).startswith('cmake-build/') else real_isdir(p)
+        with mock.patch.object(self.build, 'run_cmd', fake_run), \
+             mock.patch.object(self.build.os.path, 'isdir', no_build_dir):
             r = self.build.cmake_board('espressif_s3_devkitc', [], None, [],
                                        ['examples-membrowse-upload'],
                                        examples=['device/cdc_msc_freertos'])
@@ -2168,9 +2176,10 @@
         self.assertIn('--upload', cmd)
         self.assertEqual(cmd[cmd.index('--target-name') + 1],
                          'espressif_s3_devkitc/cdc_msc_freertos')
-        # the whole point: the --elf path must NOT exist, so membrowse_report.py's
-        # own elf-missing check takes the --identical branch
-        self.assertFalse(os.path.isfile(cmd[cmd.index('--elf') + 1]))
+        # the whole point: --elf names the file the (absent) build dir would hold, so
+        # membrowse_report.py's own elf-missing check takes the --identical branch
+        self.assertEqual(cmd[cmd.index('--elf') + 1],
+                         'cmake-build/cmake-build-espressif_s3_devkitc/device/cdc_msc_freertos/cdc_msc_freertos.elf')
 
     def test_make_one_example_uses_make_semantics(self):
         # F1 end to end: the make path must ask skip_example with build_system='make',
@@ -2326,7 +2335,7 @@
         out = []
         for fc in sorted(glob.glob(os.path.join(REPO, 'hw/bsp/*/family.cmake'))):
             depth, uncond = 0, False
-            for line in open(fc).read().splitlines():
+            for line in _read(fc).splitlines():
                 line = line.strip()
                 if build_utils._FAMILY_MCUS_RE.match(line) and depth == 0:
                     uncond = True
@@ -2409,7 +2418,7 @@
         wanted = {ns: set() for ns in self.UNREACHABLE_TOKENS}
         for f in glob.glob(os.path.join(REPO, 'examples/*/*/*.txt')):
             if os.path.basename(f) in ('skip.txt', 'only.txt'):
-                for tok in open(f).read().split():
+                for tok in _read(f).split():
                     ns, _, name = tok.partition(':')
                     if ns in wanted and name:
                         wanted[ns].add(name)
@@ -2433,7 +2442,7 @@
     def test_the_mcx_skip_tokens_are_still_live(self):
         # the reason the mcx scrape is load-bearing rather than academic
         named = [os.path.dirname(f) for f in glob.glob(os.path.join(REPO, 'examples/*/*/skip.txt'))
-                 if 'mcu:MCXA15' in open(f).read().split()]
+                 if 'mcu:MCXA15' in _read(f).split()]
         self.assertTrue(named, 'no skip.txt names mcu:MCXA15 any more')