diff --git a/.github/workflows/batch-check.yml b/.github/workflows/batch-check.yml index e5808ed8..e877241d 100644 --- a/.github/workflows/batch-check.yml +++ b/.github/workflows/batch-check.yml @@ -331,7 +331,7 @@ jobs: # producing a circular self-skip: confirmed via the GitHub Actions API against the CI runs # for two real commits on PR #390 (efd7a5c, fd7a046) that ~27 real/conda-full-only self- # tests silently "skipped" every run while the job still reported overall SUCCESS. See - # docs/agent-closed-backlog.md's Active Backlog item 7 for the full incident writeup and + # docs/agent-closed-backlog.md's closed Item 7 entry for the full incident writeup and # PR #391 for the revert that restored those steps to unconditional (matrix.mode == # 'conda-full') form. # @@ -347,7 +347,7 @@ jobs: # itself failed -- making this the correct point to sample "is conda now really available." # # Re-wired 2026-07-27 (owner sign-off, full risk/benefit assessment in chat -- see - # docs/agent-closed-backlog.md's Active Backlog item 7 entry for the summary) after the + # docs/agent-closed-backlog.md's closed Item 7 entry for the summary) after the # corrected POSITION # above was empirically confirmed working across two real conda-full runs (PR #395, #396: # `available` correctly read `true` both times). The 27 downstream conda-full-only self-test @@ -440,7 +440,7 @@ jobs: $row | Add-Content 'tests\~test-results.ndjson' -Encoding Ascii $row | Add-Content 'ci_test_results.ndjson' -Encoding Ascii Write-Host '::error::Miniconda was NOT detected at the shared install path after "Self-test: real env smoke (CI-only)" completed. 27 downstream conda-full self-tests are gated on this and will have been skipped.' - Write-Host '::error::Two possible root causes, both must be checked: (1) a genuine Miniconda install failure during real env smoke -- this already independently fails that step''s own self.env.smoke.conda NDJSON row, so if that row also failed, this is likely just secondary confirmation; check tests\~envsmoke\~envsmoke_bootstrap.log and tests\~envsmoke\~setup.log below and in the job artifacts. (2) a bug in this gating mechanism itself (wrong lane/step/output reference) -- if the env-smoke step passed cleanly, treat this as case (2) and investigate the conda_avail / conda_avail_gate steps and the 27 if: conditions before assuming this is safe to ignore. See CLAUDE.md Active Backlog item 7 for the documented history of exactly this failure class (PR #390).' + Write-Host '::error::Two possible root causes, both must be checked: (1) a genuine Miniconda install failure during real env smoke -- this already independently fails that step''s own self.env.smoke.conda NDJSON row, so if that row also failed, this is likely just secondary confirmation; check tests\~envsmoke\~envsmoke_bootstrap.log and tests\~envsmoke\~setup.log below and in the job artifacts. (2) a bug in this gating mechanism itself (wrong lane/step/output reference) -- if the env-smoke step passed cleanly, treat this as case (2) and investigate the conda_avail / conda_avail_gate steps and the 27 if: conditions before assuming this is safe to ignore. See docs/agent-closed-backlog.md''s closed Item 7 entry for the documented history of exactly this failure class (PR #390).' $envsmokeLog = 'tests\~envsmoke\~envsmoke_bootstrap.log' if (Test-Path -LiteralPath $envsmokeLog) { Write-Host '--- tail of tests\~envsmoke\~envsmoke_bootstrap.log ---' diff --git a/CLAUDE.md b/CLAUDE.md index 185977e5..35dd6d03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -484,6 +484,14 @@ below instead of here (see that section's own scope note for the distinction fro Item numbers are stable cross-reference identifiers, not sequential list positions -- once an item is fully resolved it is removed from here entirely and archived (keeping its original number) in `docs/agent-closed-backlog.md`, which is why the numbering below does not start at 1. +**Exception: if a new item's number collides with a number already permanently retired by an +older, unrelated closed item, renumber the new item to the next never-used number when archiving +it, and document the collision (old number, why, cite the colliding entry) in its +closed-backlog entry.** Keeping the original number is still the default; renumbering only +happens to resolve a genuine collision, never for its own sake. Concrete precedent: items 9 and +11 (filed 2026-07-29) each collided with an older, already-closed item of the same number and +were renumbered to 17 and 16 respectively when archived -- see item 18 below and +`docs/agent-closed-backlog.md`'s Item 16/17 entries for the full trace. - **8. `[WARN] UNC paths not supported` fires unconditionally in CI on an ordinary (non-UNC) local path -- found 2026-07-29 while gathering real console-output evidence for diff --git a/docs/agent-closed-backlog.md b/docs/agent-closed-backlog.md index 220dfd47..8ed7df6d 100644 --- a/docs/agent-closed-backlog.md +++ b/docs/agent-closed-backlog.md @@ -508,14 +508,26 @@ here, since those were the two items this pass touched for unrelated reasons). **Fixed 2026-07-31.** `:try_conda_install` now sets `HP_CONDA_ALLUSERS_ATTEMPTED=1` (reset defensively at subroutine entry) immediately before the real AllUsers install attempt, and `:tci_justme` branches its log line on whether that flag is defined: the genuine-failure path - keeps the original WARN wording unchanged, while both skip paths now log + keeps the original WARN wording (now also carrying the installer's own exit code and a reason + token -- `exitCode=%HP_CONDA_ALLUSERS_RC%, reason=installer_failed`, captured immediately after + `:run_installer_timeout` returns, per a CodeRabbit review comment on this same PR asking that + external/environmental failures be made explicit in logs rather than left as a bare "failed"), + while both skip paths now log `[INFO] Miniconda AllUsers install skipped (not elevated); trying JustMe install instead.` - instead. Confirmed safe against all three existing tests that reach this code path - (`tests/selfapps_justme.ps1`, `tests/selfapps_conda_bothfail.ps1`) -- none asserted on the old - WARN text for the skip-path scenarios they exercise. Added a new regression assertion to - `tests/selfapps_justme.ps1` (`skippedWordingCorrect`/`failedWordingAbsent`) confirming the new - INFO wording fires and the old WARN wording does NOT, in the non-elevated simulation this test - already runs. + instead. The same review pass also caught that `:tci_both_failed`'s own terminal `:die` message + had the identical problem one level up -- "Miniconda install failed (both AllUsers and JustMe)" + still implied AllUsers was genuinely attempted-then-failed even when it was only ever skipped; + fixed with the same `HP_CONDA_ALLUSERS_ATTEMPTED` branch, producing + "Miniconda install failed (AllUsers skipped -- not elevated; JustMe also failed)" for the + skip-path case and leaving the original wording for a genuine double-failure. Confirmed safe + against all three existing tests that reach this code path (`tests/selfapps_justme.ps1`, + `tests/selfapps_conda_bothfail.ps1`) -- updated `selfapps_conda_bothfail.ps1`'s own assertion + to match the new terminal wording (it always sets `HP_TEST_NOT_ELEVATED=1`, so it exercises the + skip-path terminal message specifically). Added a new regression assertion to + `tests/selfapps_justme.ps1` (`skippedWordingCorrect`/`failedWordingAbsent`, checked against the + envsmoke-scoped log rather than the combined root+envsmoke text a second CodeRabbit comment + flagged as a staleness risk for a negative assertion) confirming the new INFO wording fires and + the old WARN wording does NOT, in the non-elevated simulation this test already runs. --- diff --git a/docs/demo-bootstrapper-output.md b/docs/demo-bootstrapper-output.md index e03330fa..d3f9b1d3 100644 --- a/docs/demo-bootstrapper-output.md +++ b/docs/demo-bootstrapper-output.md @@ -1534,7 +1534,7 @@ Miniconda install first attempts an AllUsers (machine-wide) install; if UAC reje the process simply isn't elevated), it skips straight to a JustMe (per-user) install instead, no wasted attempt. **Both the "skip, never attempted" path and a genuine post-attempt AllUsers failure fall through to the same shared `:tci_justme` label** (`run_setup.bat`), but (fixed -2026-07-31, Active Backlog item 16 -- see `docs/agent-closed-backlog.md`) the label +2026-07-31, Closed Active Backlog item 16, renumbered from 11 -- see `docs/agent-closed-backlog.md`) the label now checks a flag set only right before the real AllUsers install attempt, so the two paths get distinct wording instead of both unconditionally claiming AllUsers "failed." On the common non-elevated machine (skip path, `[Extrapolated Branch]` for the new wording -- not yet @@ -1546,12 +1546,13 @@ re-confirmed against a fresh CI capture): [INFO] Miniconda installed (JustMe fallback). ``` -A genuine, post-attempt AllUsers failure still gets the original WARN wording (`[Extrapolated -Branch]`, cited from source -- this branch requires a real elevated process whose AllUsers -installer genuinely fails, which no current CI hook forces without also forcing the skip path): +A genuine, post-attempt AllUsers failure still gets the original WARN wording, now also carrying +the installer's own exit code and a reason token (`[Extrapolated Branch]`, cited from source -- +this branch requires a real elevated process whose AllUsers installer genuinely fails, which no +current CI hook forces without also forcing the skip path): ``` -[WARN] Miniconda AllUsers install failed; retrying with JustMe. +[WARN] Miniconda AllUsers install failed (exitCode=1, reason=installer_failed); retrying with JustMe. ``` **If JustMe ALSO fails** (both installation options exhausted; REAL CI CAPTURE for the skip-path @@ -1560,7 +1561,7 @@ lines, `[Extrapolated Branch]` for the now-corrected wording): ``` [INFO] Not elevated; skipping AllUsers Miniconda install. [INFO] Miniconda AllUsers install skipped (not elevated); trying JustMe install instead. -[ERROR] Miniconda install failed (both AllUsers and JustMe). +[ERROR] Miniconda install failed (AllUsers skipped -- not elevated; JustMe also failed). ``` This is a genuine `:die` (`state=error`), not a graceful degrade -- with no Python interpreter diff --git a/run_setup.bat b/run_setup.bat index 2e014278..e66eddb5 100644 --- a/run_setup.bat +++ b/run_setup.bat @@ -4481,7 +4481,8 @@ exit /b %HP_INSTALLER_RC% rem derived requirement: AllUsers install can fail when UAC rejects elevation even for admin accounts. rem JustMe is the non-admin fallback that installs under the user profile instead. rem Both attempts reuse the already-downloaded installer at %TEMP%\miniconda.exe (no re-download). -rem derived requirement: [Active Backlog item 11] track whether AllUsers was actually launched vs. +rem derived requirement: [Active Backlog item 16, renumbered from 11 -- see docs/agent-closed-backlog.md] +rem track whether AllUsers was actually launched vs. rem only skipped, so :tci_justme's own log line can tell the two apart instead of unconditionally rem claiming AllUsers "failed" even when it was never attempted. Reset defensively at entry in case rem a future caller invokes this subroutine more than once in the same process. @@ -4505,14 +4506,16 @@ rem ContinuumIO/anaconda-issues GitHub issues reporting the silent installer han rem at extraction or the post-install script) confirm this is not a theoretical risk. 60 minutes rem is a generous ceiling above the documented ~40 min real-world duration. set "HP_CONDA_ALLUSERS_ATTEMPTED=1" +set "HP_CONDA_ALLUSERS_RC=" call :run_installer_timeout "%TEMP%\miniconda.exe" "/InstallationType=AllUsers /AddToPath=0 /RegisterPython=0 /S /D=%MINICONDA_ROOT%" 3600000 "Miniconda AllUsers" -if errorlevel 1 goto :tci_justme +set "HP_CONDA_ALLUSERS_RC=%ERRORLEVEL%" +if not "%HP_CONDA_ALLUSERS_RC%"=="0" goto :tci_justme set "HP_CONDA_INSTALL_MODE=AllUsers" call :log "[INFO] Miniconda installed successfully." goto :eof :tci_justme if defined HP_CONDA_ALLUSERS_ATTEMPTED ( - call :log "[WARN] Miniconda AllUsers install failed; retrying with JustMe." + call :log "[WARN] Miniconda AllUsers install failed (exitCode=%HP_CONDA_ALLUSERS_RC%, reason=installer_failed); retrying with JustMe." ) else ( call :log "[INFO] Miniconda AllUsers install skipped (not elevated); trying JustMe install instead." ) @@ -4532,7 +4535,14 @@ set "HP_CONDA_INSTALL_MODE=JustMe" call :log "[INFO] Miniconda installed (JustMe fallback)." goto :eof :tci_both_failed -call :die "[ERROR] Miniconda install failed (both AllUsers and JustMe)." +rem derived requirement: [Active Backlog item 16] the terminal message must not imply AllUsers was +rem genuinely attempted-then-failed when it was only ever skipped (not elevated) -- mirrors the +rem same HP_CONDA_ALLUSERS_ATTEMPTED distinction :tci_justme already makes above. +if defined HP_CONDA_ALLUSERS_ATTEMPTED ( + call :die "[ERROR] Miniconda install failed (both AllUsers and JustMe)." +) else ( + call :die "[ERROR] Miniconda install failed (AllUsers skipped -- not elevated; JustMe also failed)." +) goto :eof :conda_base_update diff --git a/tests/selfapps_conda_bothfail.ps1 b/tests/selfapps_conda_bothfail.ps1 index da878681..9e752154 100644 --- a/tests/selfapps_conda_bothfail.ps1 +++ b/tests/selfapps_conda_bothfail.ps1 @@ -9,8 +9,11 @@ # New hook: HP_TEST_FORCE_JUSTME_FAIL=1 (run_setup.bat, :tci_justme) skips the real JustMe # `start "" /wait` call and forces a nonzero result deterministically. Combined with # HP_TEST_NOT_ELEVATED=1 (already reaches :tci_justme by skipping straight past the AllUsers -# attempt), this reaches :tci_both_failed -> :die "[ERROR] Miniconda install failed (both -# AllUsers and JustMe)." without depending on a genuinely broken installer/ACL environment. +# attempt), this reaches :tci_both_failed -> :die "[ERROR] Miniconda install failed (AllUsers +# skipped -- not elevated; JustMe also failed)." without depending on a genuinely broken +# installer/ACL environment. (Message wording updated for Active Backlog item 16, renumbered +# from 11 -- see docs/agent-closed-backlog.md -- to stop implying AllUsers was genuinely +# attempted-then-failed when this scenario always skips it.) # # derived requirement: Miniconda installs to the SHARED, machine-wide %PUBLIC%\Documents\ # Miniconda3 path, not a per-test-directory location -- if an EARLIER step in the same CI job @@ -112,7 +115,11 @@ try { $notElevatedSkip = $combined -match [regex]::Escape('Not elevated; skipping AllUsers Miniconda install.') $justmeFailHookFired = $combined -match [regex]::Escape('HP_TEST_FORCE_JUSTME_FAIL=1; simulating JustMe install failure') - $bothFailedMsgFound = $combined -match [regex]::Escape('Miniconda install failed (both AllUsers and JustMe)') + # derived requirement: [Active Backlog item 16, renumbered from 11 -- see docs/agent-closed- + # backlog.md] :tci_both_failed's terminal message is now skip-aware -- this scenario always + # sets HP_TEST_NOT_ELEVATED=1, so AllUsers was skipped, never attempted, and the correct + # terminal wording says so instead of implying a genuine AllUsers install failure. + $bothFailedMsgFound = $combined -match [regex]::Escape('Miniconda install failed (AllUsers skipped -- not elevated; JustMe also failed)') $statusPath = Join-Path $workDir '~bootstrap.status.json' $statusText = if (Test-Path -LiteralPath $statusPath) { Get-Content -LiteralPath $statusPath -Raw } else { $null } diff --git a/tests/selfapps_justme.ps1 b/tests/selfapps_justme.ps1 index 738a4773..f1f48fe0 100644 --- a/tests/selfapps_justme.ps1 +++ b/tests/selfapps_justme.ps1 @@ -57,12 +57,17 @@ $combinedText = $setupText + $mainSetupText $notElevatedSkip = $combinedText -match 'Not elevated; skipping AllUsers Miniconda install\.' $justmeInstalled = $combinedText -match 'Miniconda installed \(JustMe fallback\)' -# derived requirement: [Active Backlog item 11 fix] the shared :tci_justme label must NOT claim -# AllUsers "failed" when it was only ever skipped (never launched) -- this scenario's own -# HP_TEST_NOT_ELEVATED=1 takes the skip path, so the correct line is the INFO "skipped" wording, -# and the old unconditional WARN "failed" wording must NOT appear at all in this run. -$skippedWordingCorrect = $combinedText -match 'Miniconda AllUsers install skipped \(not elevated\); trying JustMe install instead\.' -$failedWordingAbsent = -not ($combinedText -match 'Miniconda AllUsers install failed; retrying with JustMe\.') +# derived requirement: [Active Backlog item 16, renumbered from 11 -- see docs/agent-closed- +# backlog.md] the shared :tci_justme label must NOT claim AllUsers "failed" when it was only ever +# skipped (never launched) -- this scenario's own HP_TEST_NOT_ELEVATED=1 takes the skip path, so +# the correct line is the INFO "skipped" wording, and the old unconditional WARN "failed" wording +# must NOT appear at all in this run. Checked against $setupText (the envsmoke-scoped log for +# THIS specific sub-bootstrap) rather than $combinedText -- unlike the two pre-existing presence +# checks above, a negative ("must NOT appear") assertion would be vulnerable to stale/unrelated +# content in the shared repo-root ~setup.log if it were ever written by an earlier, different step +# in the same job. +$skippedWordingCorrect = $setupText -match 'Miniconda AllUsers install skipped \(not elevated\); trying JustMe install instead\.' +$failedWordingAbsent = -not ($setupText -match 'Miniconda AllUsers install failed; retrying with JustMe\.') $pass = $notElevatedSkip -and $justmeInstalled -and $skippedWordingCorrect -and $failedWordingAbsent diff --git a/tests/test_audit_console_messages.py b/tests/test_audit_console_messages.py new file mode 100644 index 00000000..254fc15d --- /dev/null +++ b/tests/test_audit_console_messages.py @@ -0,0 +1,80 @@ +from tools import audit_console_messages as acm + + +def test_normalize_env_var(): + assert acm.normalize('[INFO] Log: %LOG%') == '[INFO] Log: ' + + +def test_normalize_positional_param(): + assert acm.normalize('[ERROR] Workspace path invalid: %~dp0') == '[ERROR] Workspace path invalid: ' + assert acm.normalize('*** Using drag-and-drop file: %~1') == '*** Using drag-and-drop file: ' + + +def test_normalize_for_loop_var(): + assert acm.normalize('[WARN] Repair failed: %%M') == '[WARN] Repair failed: ' + assert acm.normalize('[WARN] zip too small (%%~zS bytes)') == '[WARN] zip too small ( bytes)' + + +def test_normalize_literal_percent_not_consumed_by_later_var(): + # A literal, isolated '%' earlier in the line must not be greedily paired with a + # real %VAR% later in the same line. + assert acm.normalize('10% free on %DRIVE%') == '10% free on ' + + +def test_normalize_adjacent_expansions(): + # Realistic adjacent expansions (a separator between them, as always occurs in actual + # run_setup.bat message text) each normalize independently. + assert acm.normalize('rc=%RC% size=%SIZE%') == 'rc= size=' + + +def test_extract_records_skips_echo_control_tokens(tmp_path): + bat = tmp_path / 'run_setup.bat' + bat.write_text('@echo off\necho.\necho on\necho [INFO] real message\n', encoding='ascii') + records = acm.extract_records(bat) + assert records == [(4, '[INFO] real message')] + + +def test_extract_records_case_insensitive_call_log(tmp_path): + bat = tmp_path / 'run_setup.bat' + bat.write_text('CALL :LOG "[INFO] upper case call"\n', encoding='ascii') + records = acm.extract_records(bat) + assert records == [(1, '[INFO] upper case call')] + + +def test_extract_records_skips_redirected_lines(tmp_path): + bat = tmp_path / 'run_setup.bat' + bat.write_text( + 'echo not visible >> log.txt\n' + 'call :log "not visible either" >> log.txt\n' + 'echo [INFO] visible line\n', + encoding='ascii', + ) + records = acm.extract_records(bat) + assert records == [(3, '[INFO] visible line')] + + +def test_is_covered_true_when_all_segments_present(): + assert acm.is_covered('[INFO] Log: ', '... [INFO] Log: C:\\work\\ ...') is True + + +def test_is_covered_false_when_missing(): + assert acm.is_covered('[WARN] never documented anywhere', 'totally unrelated corpus text') is False + + +def test_main_reports_error_on_missing_file(tmp_path, capsys): + missing = tmp_path / 'nope.bat' + doc = tmp_path / 'demo.md' + doc.write_text('placeholder', encoding='utf-8') + result = acm.main(['--file', str(missing), '--demo-doc', str(doc)]) + captured = capsys.readouterr() + assert result == 2 + assert 'not found' in captured.err + + +def test_main_reports_error_on_directory_path(tmp_path, capsys): + doc = tmp_path / 'demo.md' + doc.write_text('placeholder', encoding='utf-8') + result = acm.main(['--file', str(tmp_path), '--demo-doc', str(doc)]) + captured = capsys.readouterr() + assert result == 2 + assert 'not found' in captured.err diff --git a/tools/audit_console_messages.py b/tools/audit_console_messages.py index b72e0aa7..1f0dbf2d 100644 --- a/tools/audit_console_messages.py +++ b/tools/audit_console_messages.py @@ -7,9 +7,11 @@ What it does: 1. Extracts every `echo ` and `call :log ""` line from run_setup.bat - (skipping blank/`echo off`/`echo on` control lines and lines redirected to a - file with `>>`, which never reach the live console) and normalizes `%VAR%` - tokens to a placeholder so lines differing only by runtime substitution match. + (case-insensitively, tolerating a leading `@`; skipping blank/`echo off`/ + `echo on` control lines and lines redirected to a file with `>>`, which never + reach the live console) and normalizes `%VAR%`, `%~dp0`/`%~1`-style + positional parameters, and `%%M`-style for-loop variables to a single + placeholder so lines differing only by runtime substitution match. 2. Splits each normalized message on its placeholder tokens and checks whether every resulting literal segment (length >= 6, to skip noise) appears anywhere in the demo doc's text -- a heuristic substring match, not a semantic one. @@ -41,7 +43,17 @@ def normalize(text: str) -> str: - text = re.sub(r'%[^%]+%', '', text) + # %%VAR / %%~zS -style for-loop variable references -- must run before the single-percent + # patterns below, since %% would otherwise look like an empty %...% pair to them. + text = re.sub(r'%%~?[A-Za-z][A-Za-z0-9]*', '', text) + # %~dp0 / %~1 / %~nx1 -style positional/modified batch parameters, and bare %1-%9. + text = re.sub(r'%~[A-Za-z$:]*[0-9]', '', text) + text = re.sub(r'%[0-9]\b', '', text) + # %VAR% environment variable references. Requires no whitespace inside the delimiters (real + # batch variable names never contain spaces) so a literal, isolated '%' earlier in the same + # line (e.g. "10% free on %DRIVE%") can't be greedily treated as this pattern's opening + # delimiter and swallow the literal text up to the next real '%'. + text = re.sub(r'%[^%\s]+%', '', text) return re.sub(r'\s+', ' ', text).strip() @@ -49,18 +61,20 @@ def extract_records(bat_path: Path): """Return a list of (line_no, normalized_text) for console-visible lines.""" records = [] for i, raw in enumerate(bat_path.read_text(encoding='ascii', errors='replace').splitlines(), 1): - line = raw.strip() + line = raw.strip().lstrip('@') m = re.match(r'^echo\s+(.*)$', line, re.IGNORECASE) if m: body = m.group(1) - if body.strip() in ('.', 'off', 'on'): + if body.strip().lower() in ('.', 'off', 'on'): continue if '>>' in raw: continue records.append((i, normalize(body))) continue - m = re.search(r'call :log\s+["\']([^"\']*)["\']', line) + m = re.search(r'call :log\s+["\']([^"\']*)["\']', line, re.IGNORECASE) if m: + if '>>' in raw: + continue records.append((i, normalize(m.group(1)))) return records @@ -73,26 +87,30 @@ def is_covered(normalized: str, corpus: str) -> bool: return all(seg in corpus for seg in segments) -def main() -> int: +def main(argv=None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--file', default='run_setup.bat', help='Batch file to scan (default: run_setup.bat)') parser.add_argument('--demo-doc', default='docs/demo-bootstrapper-output.md', help='Demo doc to check coverage against (default: docs/demo-bootstrapper-output.md)') parser.add_argument('--include-test-only', action='store_true', help='Also report gaps in HP_TEST-gated / simulated lines (excluded by default, see module docstring)') - args = parser.parse_args() + args = parser.parse_args(argv) bat_path = Path(args.file) doc_path = Path(args.demo_doc) - if not bat_path.exists(): + if not bat_path.is_file(): print(f"error: {bat_path} not found", file=sys.stderr) return 2 - if not doc_path.exists(): + if not doc_path.is_file(): print(f"error: {doc_path} not found", file=sys.stderr) return 2 - records = extract_records(bat_path) - corpus = doc_path.read_text(encoding='utf-8', errors='replace') + try: + records = extract_records(bat_path) + corpus = doc_path.read_text(encoding='utf-8', errors='replace') + except OSError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 missing = [] test_only_missing = []