Skip to content
Merged
38 changes: 38 additions & 0 deletions UtilitiesCS.Test/HelperClasses/FileIO2_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,44 @@ public async Task WriteTextFileAsync_WhenWriteFailsAfterOpen_ShouldReturnFalseWi
midWriteResult.Should().BeFalse();
}

/// <summary>
/// A <see cref="DirectoryNotFoundException"/> raised while opening the writer is a structural
/// failure that no retry can resolve: the target directory does not exist, so waiting and
/// trying again cannot succeed. The observable proof that no retry occurred is a single
/// writer-factory invocation and a delay-delegate invocation count of zero.
/// </summary>
[TestMethod]
public async Task WriteTextFileAsync_WhenDirectoryDoesNotExist_ShouldReturnFalseWithoutRetrying()
{
// Arrange
int missingDirectoryFactoryCalls = 0;
int missingDirectoryDelayCalls = 0;
using var cts = new CancellationTokenSource();

// Act
bool missingDirectoryResult = await FileIO2.WriteTextFileAsync(
"irrelevant.csv",
new[] { "alpha" },
"irrelevant-folder",
cts.Token,
writerFactory: _ =>
{
missingDirectoryFactoryCalls++;
throw new DirectoryNotFoundException("Simulated missing directory.");
},
delay: (ms, t) =>
{
missingDirectoryDelayCalls++;
return Task.CompletedTask;
}
);

// Assert
missingDirectoryFactoryCalls.Should().Be(1);
missingDirectoryDelayCalls.Should().Be(0);
missingDirectoryResult.Should().BeFalse();
}

/// <summary>
/// Retry exhaustion: every open attempt fails, so the loop consumes its whole 100-attempt
/// budget and awaits 99 delays between them. No filesystem access and no wall-clock wait.
Expand Down
8 changes: 8 additions & 0 deletions UtilitiesCS/To Depricate/FileIO2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ internal static async Task<bool> WriteTextFileAsync(
// error, so this is the single point at which success is established.
return true;
}
catch (DirectoryNotFoundException ex)
{
logger.Error(
$"Failed to write to {filepath}: the target directory does not exist.",
ex
);
return false;
}
catch (IOException ex)
{
if (opened)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Code Review — narrow-fileio2-retryable-exception-set (Issue #707)

- Reviewed: 2026-09-03T08-32
- Diff scope: `67c2e3b0eca90a52e9aee82ccd100acce4722169..HEAD -- ":(exclude).claude"` (50 files; footprint is 2 source files)

## Production Change: `UtilitiesCS/To Depricate/FileIO2.cs`

```csharp
catch (DirectoryNotFoundException ex)
{
logger.Error(
$"Failed to write to {filepath}: the target directory does not exist.",
ex
);
return false;
}
catch (IOException ex)
{
if (opened) { ... }
Interlocked.Increment(ref attempts);
if (attempts >= 100) { ... }
await delayAsync(100, token);
}
```

- **Correctness**: `DirectoryNotFoundException` derives from `IOException`; C# requires the more-derived catch clause to appear first in the same `try`, which this diff does (verified by direct read of the compiled method — `catch (DirectoryNotFoundException ex)` at line 126, `catch (IOException ex)` at line 134). Reversing the order is CS0160, self-detecting at compile time; the analyzer and nullable rebuilds both succeeded at 0/0, corroborating the ordering is valid.
- **Behavior**: the new block does not call `Interlocked.Increment(ref attempts)` or `await delayAsync(...)` — confirmed both by direct grep of the diff hunk and by the regression test's assertion (`missingDirectoryDelayCalls.Should().Be(0)`, `missingDirectoryFactoryCalls.Should().Be(1)`). The general `catch (IOException ex)` block is untouched (single-hunk diff), so the retry-exhaustion path for other `IOException` subtypes (e.g. bare sharing-violation `IOException`) is unaffected.
- **Logging**: uses the same `logger.Error(string, Exception)` two-argument overload as the sibling `catch (IOException ex)` block, with a message distinguishing the missing-directory case from the generic retry-exhaustion message. Consistent with the repo's established logging pattern in this file; no ad-hoc console output introduced.
- **Design principles**: minimal, additive, single-responsibility catch block. No opportunistic refactor of the surrounding method. Matches the repo's Bugfix Workflow (minimal targeted fix, no broader restructuring). File remains 301 lines, well under the 500-line limit.
- **Naming**: no new identifiers introduced in production code beyond the caught exception's bound name `ex`, matching the sibling block's convention.
- **Nullability / error handling**: no new nullable surface; exception is caught, logged, and the method returns its existing `Task<bool>` contract unchanged. Fail-fast is preserved — the method still does not throw on a failed write, consistent with the documented `<returns>` contract above it (unmodified by this diff).

No defects identified in the production change.

## Test Change: `UtilitiesCS.Test/HelperClasses/FileIO2_Tests.cs`

- New test `WriteTextFileAsync_WhenDirectoryDoesNotExist_ShouldReturnFalseWithoutRetrying` (38 lines) follows the exact structural pattern of its sibling `WriteTextFileAsync_WhenWriteFailsAfterOpen_ShouldReturnFalseWithoutRetrying` immediately above it: local call-counters, injected `writerFactory`/`delay` delegates, `CancellationTokenSource` disposed via `using`, three FluentAssertions calls (`factoryCalls.Should().Be(1)`, `delayCalls.Should().Be(0)`, `result.Should().BeFalse()`).
- Assertion order places the two count assertions before the boolean-result assertion; this matches the RED-first evidence (`p2-t3-missingdirectory-fail-before.md`) where the pre-fix failure surfaces on the first assertion (`missingDirectoryFactoryCalls.Should().Be(1)`, actual 100) — a clear, actionable failure message pinpointing the retry-count defect rather than only the boolean outcome.
- No temp files, no real filesystem access (`writerFactory` throws before any I/O), no real wall-clock delay (`delay` is a synchronous stub returning `Task.CompletedTask`, and is asserted never called). Fully compliant with UT4 (external dependencies / temp file prohibition) and the Determinism Infrastructure rules (no `Thread.Sleep`/`Task.Delay` in test code).
- XML-doc-style comment above the test explains both the scenario (`DirectoryNotFoundException` is structurally non-retryable) and the observable proof shape (factory-call and delay-call counts), satisfying the "document intent" requirement.
- Test file remains 373 lines, well under the 500-line limit.

No defects identified in the test change.

## Evidence-Trail Observations (non-blocking)

1. **AC9 wording gap, self-disclosed.** `spec.md`'s AC9 text says "with all tests green" for the full `vstest.console.exe` run against `UtilitiesCS.Test`, but the full-suite run has 17 pre-existing, unrelated Deedle/F# failures in both baseline and post-change runs (identical sets, confirmed by this review — see feature-audit). The executor's own `p6-t9-ac9.md` evidence explicitly flags this literal-text gap and reconciles it against the plan's narrower task-level acceptance text (full `FileIO2_Tests` suite green) rather than silently checking the box. This is good practice — the discrepancy is surfaced, not hidden — but it means AC9 as literally worded in `spec.md` is not fully satisfied by a strict reading. See feature-audit for the disposition.
2. **Stale-merge-base evidence, one file not caught up.** `evidence/qa-gates/p6-t8-ac8-caller-scope.md` (AC8 verification) computed its diff using the stale `BASE_SHA` (`687f15fb`) from `p0-t7-base-ref.md`, before the discrepancy was identified and disclosed in the later `p7-t2-commit-verification.md`. This reviewer confirmed `687f15fb` is an ancestor of the correct reconciliation-merge base `67c2e3b0` (`git merge-base --is-ancestor` exit 0), so the 360-path diff AC8 searched is a strict superset of the correct 50-path scope; the negative-match conclusion for the two excluded caller files is unaffected. No corrective action needed, but a future pass could backfill the discrepancy note into `p6-t8-ac8-caller-scope.md` for internal consistency with `p7-t2`.
3. **Cobertura delta line-count discrepancy is unexplained in evidence.** The raw source diff adds 8 lines to `FileIO2.cs`; the Cobertura-derived new-code delta (`p5-t8-coverage-delta.md`) reports 14 new "valid" and 14 new "covered" lines. The evidence attributes this to the async state-machine class-merge transform but does not show the underlying per-class breakdown that would make the 8-vs-14 gap independently reproducible. The acceptance conclusion (100% new-code coverage) is not in question — baseline and post-change both went through the identical merge transform — but a future evidence pass could attach the raw (pre-merge) per-class Cobertura fragment for full auditability.

None of these three observations rises to a blocking finding; all are documentation/traceability quality notes on an otherwise clean change.

## Summary

The production fix is a single, minimal, compiler-verified-correct catch-block insertion that satisfies the issue's stated Expected Behavior. The regression test is well-isolated, deterministic, and demonstrably RED-before/GREEN-after. No best-practice violations (naming, structure, error handling, file size, dependency isolation) were found in either changed file.

**No blocking code-review findings.**
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Timestamp: 2026-09-03T11-59
Command: dotnet tool restore
EXIT_CODE: 0
Output Summary: Tool 'csharpier' (version '1.2.6') was restored. Restore was successful. Manifest-pinned CSharpier version confirmed as 1.2.6.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Timestamp: 2026-09-03T11-59
Command: Test-Path packages ; pwsh -File scripts/vscode/Invoke-Restore.ps1
EXIT_CODE: 0

OBSERVED_PACKAGES_PRESENT (before): False
OBSERVED_PACKAGES_PRESENT (after): True

Output Summary: MSBuild restore succeeded, 172 package(s) restored to packages.config projects, 0 Warning(s), 0 Error(s). `Test-Path packages` returns True after the restore.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
Timestamp: 2026-09-03T12-05
Command: PowerShell scan of every non-packages `*.csproj` under the worktree root, resolving each `<Analyzer Include>` path joined to the project directory, plus a `packages.config` Meziantou.Analyzer/Roslynator.Analyzers version cross-check.
EXIT_CODE: 0

Per-project results (18 first-party csproj files scanned, `packages\` subtree excluded):

| Project | Analyzer Items | Resolved | Unresolved |
|---|---|---|---|
| QuickFiler\QuickFiler.csproj | 9 | 9 | 0 |
| QuickFiler.Test\QuickFiler.Test.csproj | 11 | 11 | 0 |
| SVGControl\SVGControl.csproj | 0 | 0 | 0 |
| SVGControl.Test\SVGControl.Test.csproj | 2 | 2 | 0 |
| Tags\Tags.csproj | 9 | 9 | 0 |
| Tags.Test\Tags.Test.csproj | 11 | 11 | 0 |
| TaskMaster\TaskMaster.csproj | 9 | 9 | 0 |
| TaskMaster.Test\TaskMaster.Test.csproj | 11 | 11 | 0 |
| TaskTree\TaskTree.csproj | 9 | 9 | 0 |
| TaskTree.Test\TaskTree.Test.csproj | 11 | 11 | 0 |
| TaskVisualization\TaskVisualization.csproj | 9 | 9 | 0 |
| TaskVisualization.Test\TaskVisualization.Test.csproj | 11 | 11 | 0 |
| ToDoModel\ToDoModel.csproj | 9 | 9 | 0 |
| ToDoModel.Test\ToDoModel.Test.csproj | 11 | 11 | 0 |
| UtilitiesCS\UtilitiesCS.csproj | 9 | 9 | 0 |
| UtilitiesCS.Test\UtilitiesCS.Test.csproj | 11 | 11 | 0 |
| VBFunctions\VBFunctions.csproj | 9 | 9 | 0 |
| VBFunctions.Test\VBFunctions.Test.csproj | 11 | 11 | 0 |

TOTAL_RESOLVED: 152
TOTAL_UNRESOLVED: 0

For every project carrying a `packages.config` entry for `Meziantou.Analyzer` and `Roslynator.Analyzers`, the version token embedded in the `<Analyzer Include>` path matched the `packages.config`-declared version (`Meziantou.Analyzer` 3.0.194, `Roslynator.Analyzers` 5.0.0) in all cases.

ANALYZER_SKEW_BLOCKING: none

Output Summary: 152 of 152 `<Analyzer Include>` items resolve to an on-disk DLL across all 18 first-party projects; 0 unresolved. Meziantou/Roslynator version tokens agree with packages.config on every project that references them. No analyzer wiring skew detected in this execution pass.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Timestamp: 2026-09-03T12-06
Command: if (-not (Get-Command dotnet-coverage -ErrorAction SilentlyContinue)) { dotnet tool install --global dotnet-coverage } ; dotnet-coverage --version
EXIT_CODE: 0
Output Summary: dotnet-coverage was already available (Get-Command succeeded, install skipped); `dotnet-coverage --version` printed 18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342, exit 0.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Timestamp: 2026-09-03T12-07
Command: dotnet tool run csharpier check .
EXIT_CODE: 0
Output Summary: Checked 1576 files in 5966ms.

PRE_EXISTING_FORMAT_DRIFT: none (EXIT_CODE 0, no drift list reported).
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Timestamp: 2026-09-03T12-15
Command: & $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true
(where $msbuild resolved via vswhere to "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe")
EXIT_CODE: 0

BASELINE_ANALYZER_WARNINGS: 0
BASELINE_ANALYZER_ERRORS: 0

Output Summary: Build succeeded. 0 Warning(s), 0 Error(s). Time Elapsed 00:00:28.88.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Timestamp: 2026-09-03T12-25
Command: & $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true
(where $msbuild resolved via vswhere to "C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe")
EXIT_CODE: 0

BASELINE_NULLABLE_WARNINGS: 0
BASELINE_NULLABLE_ERRORS: 0

Output Summary: Build succeeded. 0 Warning(s), 0 Error(s).
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Timestamp: 2026-09-03T12-35

KNOWN_ENVIRONMENT_DEFECT: issue #752 — scripts/vscode/Invoke-MSTestWithCoverage.ps1 (~line 301) excludes any assembly whose absolute path contains a `.claude` segment. This worktree is rooted under `.claude/worktrees/`, so the literal task command `pwsh -File scripts/vscode/Invoke-MSTestWithCoverage.ps1 -SearchRoot UtilitiesCS.Test -Configuration Debug` throws `No test assemblies found ... Build first.` even though the build succeeded (confirmed in P0-T15/P0-T16). Substituted per the delegation-prompt-authorized workaround below; this is a mechanical toolchain substitution, not a plan deviation.

Command (substituted): resolved $vstest via vswhere (Tool Resolution rule), located the built UtilitiesCS.Test.dll under UtilitiesCS.Test\bin\Debug (workspace-root-prefix checked), then:
dotnet-coverage collect "<vstest>" "<dll>" /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook "/Logger:trx;LogFileName=p0-t17.trx" /ResultsDirectory:"coverage\testresults\p0-t17" --output "coverage\coverage.cobertura.xml" --output-format cobertura

Where:
VSTEST_PATH: C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe
DISCOVERED_ASSEMBLY: C:\Users\DanMoisan\repos\TaskMaster\.claude\worktrees\agent-a1cd2e1147794981e\UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll (begins with the workspace root C:\Users\DanMoisan\repos\TaskMaster\.claude\worktrees\agent-a1cd2e1147794981e)

EXIT_CODE: 1 (dotnet-coverage/vstest exit code; vstest reports non-zero when any test fails, consistent with the 17 pre-existing failures below, not with a tooling error)

TOTAL_TESTS: 4785
PASSED: 4768
FAILED: 17
SKIPPED: 0

Output Summary: Test Run Failed overall (17 of 4785 failed), but all 11 FileIO2_Tests [TestMethod]s passed: DeleteTextFile_WhenTargetIsMissing_ShouldNotThrow, WriteTextFile_WhenDevicePathIsUsed_ShouldThrowNotSupportedException, WriteTextFileAsync_WhenWriteFailsAfterOpen_ShouldReturnFalseWithoutRetrying, WriteTextFileAsync_WhenEveryOpenAttemptFails_ShouldReturnFalseAfterBudget, WriteTextFileAsync_WhenTransientOpenFailureThenSucceeds_ShouldReturnTrueAndWriteAllLines, WriteTextFileAsync_WhenTokenAlreadyCancelled_ShouldThrowBeforeOpening, WriteTextFileAsync_WhenCancelledDuringRetryWindow_ShouldThrowPromptly, WriteTextFileAsync_WhenRetrying_ShouldPassCallerTokenToDelay, CsvReaders_WithFixtureAndMissingFiles_ShouldRespectHeaderOptions, SplitArrayTo2D_ShouldSupportZeroAndOneBasedLayouts, CsvReadTo2D_AndCsvReadToJagged_ShouldProjectFixtureRows. The 17 failures are all Deedle/F#-related tests (e.g. DeedleDoodles, GetColumnEid_WithStringValues_ReturnsOrdinalSeries, FromArray2D_EmptyData_ReturnsFrameWithColumnsButNoRows) that throw `System.Security.VerificationException: Operation could destabilize the runtime` from `Deedle.Reflection`'s type initializer — a known dotnet-coverage/Deedle F# IL-instrumentation incompatibility unrelated to this fix's footprint. Full failed-test enumeration recorded in evidence/baseline/p0-t20-baseline-failure-set.md.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Timestamp: 2026-09-03T12-40

Source: evidence/baseline/p0-t17-utilitiescs-coverage.md's coverage/coverage.cobertura.xml (raw dotnet-coverage cobertura output; does NOT carry a `<sources>` element).

DERIVATION_BRANCH: raw dotnet-coverage cobertura output lacks `<sources>`, so the governing derivation dot-sourced scripts/vscode/Invoke-MSTestWithCoverage.Helpers.ps1 and applied ConvertTo-KoverageCoberturaXml (with the Get-KoverageProjectAllowlist first-party, non-`.Test`-suffixed project allowlist: QuickFiler, SVGControl, Tags, TaskMaster, TaskTree, TaskVisualization, ToDoModel, UtilitiesCS, VBFunctions), producing a transformed document that does carry `<sources>`; its root `coverage` attributes are read below.

BASELINE_LINE_RATE: 0.602252
BASELINE_LINES_COVERED: 38938
BASELINE_LINES_VALID: 64654
BASELINE_BRANCH_RATE: 0.557373
BASELINE_BRANCHES_COVERED: 9268
BASELINE_BRANCHES_VALID: 16628

Output Summary: First-party (non-test) repository denominator after allowlist filtering and class-by-filename merge: line-rate 60.2% (38938/64654), branch-rate 55.7% (9268/16628). This is a whole-first-party-package figure, not scoped solely to UtilitiesCS, because dotnet-coverage instruments the entire UtilitiesCS.Test host process and the ConvertTo-KoverageCoberturaXml allowlist keeps every first-party production package, matching CLAUDE.md's repository-wide coverage method. The identical derivation is applied again at P5-T6 so the before/after comparison is self-consistent regardless of denominator breadth.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Timestamp: 2026-09-03T12-45

Source: coverage/coverage.cobertura.transformed.p0-t18.xml (same transform as P0-T18).

Aggregated every Cobertura `class` element whose `filename` attribute ends with `FileIO2.cs`. The raw pre-merge document (coverage/coverage.cobertura.xml) carries 4 such class elements (`UtilitiesCS.FileIO2`, `UtilitiesCS.FileIO2.<>c`, `UtilitiesCS.FileIO2.<>c__DisplayClass11_0`, `UtilitiesCS.FileIO2.<WriteTextFileAsync>d__5` — the async state machine), confirming the plan's stated risk (async state machine emits a separate class). `Merge-CoberturaClassesByFilename` in the governing transform combines all four into a single `UtilitiesCS.FileIO2` class keyed by filename before this task reads it, so this task's summation over the transformed document's single remaining class is the correct aggregate (equivalent to summing raw per-class `<line>` elements across all four).

BASELINE_FILEIO2_LINES_COVERED: 241
BASELINE_FILEIO2_LINES_VALID: 276

Output Summary: FileIO2.cs baseline: 241/276 lines covered (line-rate 0.875912 on the merged class), aggregated across the file's 4 raw pre-merge classes including the async state machine.
Loading
Loading