diff --git a/UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs b/UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs new file mode 100644 index 000000000..97a235cea --- /dev/null +++ b/UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Deedle; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Microsoft.Office.Interop.Outlook; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS; +using UtilitiesCS.Test.TestHelpers; +using Outlook = Microsoft.Office.Interop.Outlook; + +namespace UtilitiesCS.Test.Extensions +{ + /// + /// Deadline-expiry regression tests for . + /// The ETL deadline is placed under an injected clock, so no test here waits on wall time. + /// + [TestClass] + // Not parallelized: this class drives a real Task.Run gate and shares the DfDeedle logger, + // exactly as DfDeedleQfcColumnTimeoutTests does. + [DoNotParallelize] + public class DfDeedleEtlTimeoutTests + { + /// A ProgressTracker whose Report overrides do nothing, so progress reporting + /// cannot influence timing or produce output during the test. + private sealed class SilentProgressTracker : ProgressTracker + { + public SilentProgressTracker() + : base(new CancellationTokenSource()) { } + + public override void Report((int Value, string JobName) report) { } + + public override void Report(double value) { } + + public override void Report(double value, string jobName) { } + } + + private static ProgressTracker CreateProgressTracker() + { + return new ProgressTracker(new SilentProgressTracker(), allocation: 100, startingAt: 0); + } + + /// + /// Builds a folder whose UserDefinedProperties collection carries a Triage entry, so + /// EnsureTriageColumnExists returns true and no modal-dialog seam is reached. + /// + private static Mock BuildFolderWithTriageUdp() + { + var udp = new Mock(MockBehavior.Loose); + udp.SetupGet(p => p.Name).Returns("Triage"); + + var udpList = new List { udp.Object }; + var udps = new Mock(MockBehavior.Loose); + udps.Setup(u => u.GetEnumerator()).Returns(udpList.GetEnumerator()); + + var folder = new Mock(MockBehavior.Loose); + folder.SetupGet(f => f.UserDefinedProperties).Returns(udps.Object); + folder.SetupGet(f => f.StoreID).Returns("store-1"); + folder.SetupGet(f => f.Name).Returns("Inbox"); + return folder; + } + + /// + /// Builds a strict one-row Table behind a strict Explorer. The two supplied actions are + /// the test-owned gates: the first runs inside Columns.Add("SentOn"), which is the first + /// call AddQfcColumns makes, and the second runs inside GetNextRow, which is the first + /// call the ETL row reader makes. Blocking on them makes the arming order deterministic. + /// + private static Mock BuildExplorer( + Mock folder, + System.Action onAddSentOnColumn, + System.Action onGetNextRow + ) + { + var row = new Mock(MockBehavior.Strict); + row.Setup(x => x.GetValues()) + .Returns(new object[] { "entry-1", "IPM.Note", "2024-01-01", "conv-raw", "A" }); + row.Setup(x => x.BinaryToString(4)).Returns("conv-1"); + + var columns = new Mock(MockBehavior.Loose); + var table = new Mock(MockBehavior.Strict); + table.SetupGet(x => x.Columns).Returns(columns.Object); + table.Setup(x => x.MoveToStart()); + table.Setup(x => x.GetRowCount()).Returns(1); + + var currentRow = 0; + table.Setup(x => x.EndOfTable).Returns(() => currentRow >= 1); + table + .Setup(x => x.GetNextRow()) + .Returns(() => + { + onGetNextRow(); + currentRow++; + return row.Object; + }); + + var columnNames = new[] + { + "EntryID", + "MessageClass", + "SentOn", + "ConversationId", + "Triage", + }; + columns.Setup(x => x.Count).Returns(columnNames.Length); + for (var index = 0; index < columnNames.Length; index++) + { + var column = new Mock(MockBehavior.Strict); + column.SetupGet(x => x.Name).Returns(columnNames[index]); + columns.Setup(x => x[index + 1]).Returns(column.Object); + } + + columns.Setup(x => x.Add("SentOn")).Callback(onAddSentOnColumn).Returns((Column)null); + + var tableView = new Mock(MockBehavior.Strict); + tableView.Setup(x => x.GetTable()).Returns(table.Object); + + var explorer = new Mock(MockBehavior.Strict); + explorer.SetupGet(x => x.CurrentView).Returns(tableView.Object); + explorer.SetupGet(x => x.CurrentFolder).Returns(folder.Object); + return explorer; + } + + /// + /// When the ETL row-read deadline expires, EtlAsync swallows the TimeoutException and + /// returns a null data array. GetEmailDataInViewAsync must name the folder in an + /// InvalidOperationException rather than dereferencing the null snapshot. + /// The clock is an so it is advanced only after + /// the deadline it is meant to expire has actually been armed. + /// + [TestMethod] + public async Task GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder() + { + // Arrange: two test-owned gates make the timer-arming order deterministic. Without + // them the number of Armed signals before the ETL deadline depends on a pool race. + var gateA = new ManualResetEventSlim(false); + var gateB = new ManualResetEventSlim(false); + var barrier = new ArmingBarrierTimeProvider(new FakeTimeProvider()); + var explorer = BuildExplorer(BuildFolderWithTriageUdp(), gateA.Wait, gateB.Wait); + var progress = CreateProgressTracker(); + + try + { + // Act + Task> call = DfDeedle.GetEmailDataInViewAsync( + explorer.Object, + CancellationToken.None, + new CancellationTokenSource(), + progress, + timeProvider: barrier + ); + + // Timer 1: the 3000 ms column-add deadline, deterministic because the adder is + // blocked on gate A and so the work task cannot complete first. + await barrier.Armed; + barrier.ReArm(); + gateA.Set(); + + // Timer 2: the 250 ms ETL hop deadline, deterministic because the row read is + // blocked on gate B. Advancing before this timer exists would hang the test. + await barrier.Armed; + barrier.Advance(250); + + // Assert + await FluentActions + .Awaiting(() => call) + .Should() + .ThrowAsync() + .WithMessage("*Inbox*"); + } + finally + { + // Release both gates so the orphaned Task.Run bodies complete. + gateA.Set(); + gateB.Set(); + } + } + + /// + /// The deterministic green path: every deadline on the call chain is armed on a clock + /// that never moves, so no deadline can fire regardless of host load. + /// + [TestMethod] + public async Task GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame() + { + // Arrange: no gate is engaged, so the production path runs to completion. + var explorer = BuildExplorer(BuildFolderWithTriageUdp(), () => { }, () => { }); + var progress = CreateProgressTracker(); + + // Act + Frame result = await DfDeedle.GetEmailDataInViewAsync( + explorer.Object, + CancellationToken.None, + new CancellationTokenSource(), + progress, + timeProvider: new FakeTimeProvider() + ); + + // Assert + result.RowCount.Should().Be(1); + result + .ColumnKeys.Should() + .Contain(new[] { "EntryId", "MessageClass", "ConversationId" }); + } + + /// + /// The default-delegate path of the synchronous GetEmailDataInView. Supplying no etl + /// argument runs the production DefaultTableEtl over the table, which is what every + /// existing caller does now that the TableEtlInvoker static has been removed. + /// + [TestMethod] + public void GetEmailDataInView_NoEtlArgument_UsesProductionDefaultDelegate() + { + // Arrange + var explorer = BuildExplorer(BuildFolderWithTriageUdp(), () => { }, () => { }); + + // Act + Frame result = DfDeedle.GetEmailDataInView(explorer.Object); + + // Assert + result.Should().NotBeNull(); + result.RowCount.Should().Be(1); + } + } +} diff --git a/UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs b/UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs index 0e28f6e4b..a96ff378e 100644 --- a/UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs +++ b/UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs @@ -10,6 +10,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using UtilitiesCS.OutlookObjects.Fields; +using UtilitiesCS.Test.TestHelpers; namespace UtilitiesCS.Test.Extensions { @@ -36,54 +37,6 @@ public class DfDeedleQfcColumnTimeoutTests "LastModificationTime", }; - /// - /// A forwarding every member to an inner - /// unchanged and, after forwarding CreateTimer, - /// completing a signal that the production loop has armed its next deadline. - /// - /// - /// Forwarding keeps timer ownership with the inner provider. Consecutive Advance - /// calls are prohibited: each deadline is armed only after the previous proxy faults, so - /// advancing past a deadline the loop has not created hangs the test. TrySetResult - /// is used because the loop can arm one more timer than a test drives. - /// - private sealed class ArmingBarrierTimeProvider : TimeProvider - { - private readonly FakeTimeProvider _inner; - private volatile TaskCompletionSource _armed = NewSignal(); - - internal ArmingBarrierTimeProvider(FakeTimeProvider inner) => _inner = inner; - - internal Task Armed => _armed.Task; - - internal void ReArm() => _armed = NewSignal(); - - internal void Advance(int ms) => _inner.Advance(TimeSpan.FromMilliseconds(ms)); - - public override DateTimeOffset GetUtcNow() => _inner.GetUtcNow(); - - public override long GetTimestamp() => _inner.GetTimestamp(); - - public override TimeZoneInfo LocalTimeZone => _inner.LocalTimeZone; - public override long TimestampFrequency => _inner.TimestampFrequency; - - public override ITimer CreateTimer( - TimerCallback callback, - object state, - TimeSpan dueTime, - TimeSpan period - ) - { - var timer = _inner.CreateTimer(callback, state, dueTime, period); - _armed.TrySetResult(true); - return timer; - } - - // Continuations run asynchronously so a signal never resumes a test inline. - internal static TaskCompletionSource NewSignal() => - new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - /// /// Injectable column adder that counts invocations, signals entry, then blocks on the gate /// so a deadline is the only thing able to end the attempt. The gate is a delegate. diff --git a/UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs b/UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs index 533e4f44e..d7e6fa1cd 100644 --- a/UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs +++ b/UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Deedle; using FluentAssertions; +using Microsoft.Extensions.Time.Testing; using Microsoft.Office.Interop.Outlook; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -22,14 +23,16 @@ namespace UtilitiesCS.Test.Extensions /// /// Purpose: /// Covers the COM-interface paths (HasUserDefinedProperty, EnsureTriageColumnExists, - /// AddQfcColumns), the testability-seam paths (GetEmailDataFromTable, - /// GetEmailDataInView via TableEtlInvoker, AddQfcColumnsAsync), the storage-folder - /// factory paths (FromDefaultFolder), and the pure Deedle extension methods + /// AddQfcColumns), the injected-ETL paths (GetEmailDataFromTable, GetEmailDataInView + /// via its optional etl parameter, AddQfcColumnsAsync), the storage-folder factory + /// paths (FromDefaultFolder), and the pure Deedle extension methods /// (PrintToLog, DropFirstN, Exclude, GetDuplicateEntriesByColumn). /// /// Usage: - /// Every test that modifies a static seam saves/restores the original value to - /// prevent side effects across the test run. + /// The two ETL delegates are ordinary optional parameters, so no test in this class + /// writes process-wide state to inject ETL data. + /// MessageBoxInvoker is the one remaining static seam: every test that swaps it + /// restores it in a finally block, and it has no reader on any parallel-phase path. /// [TestClass] public class DfDeedle_COM_Tests @@ -364,7 +367,7 @@ public void GetEmailDataFromTable_OneRow_ReturnsFrameWithExpectedFields() } // ---------------------------------------------------------------- - // GetEmailDataInView (using TableEtlInvoker seam) + // GetEmailDataInView (using the optional etl parameter) // ---------------------------------------------------------------- [TestMethod] @@ -396,22 +399,15 @@ public void GetEmailDataInView_WithInjectedEtlResult_ReturnsPopulatedFrame() mockExplorer.SetupGet(e => e.CurrentView).Returns(mockTableView.Object); mockExplorer.SetupGet(e => e.CurrentFolder).Returns(folderWithTriage.Object); - var originalEtl = DfDeedle.TableEtlInvoker; - DfDeedle.TableEtlInvoker = _ => (injectedData, injectedColInfo); - - try - { - // Act - Frame df = DfDeedle.GetEmailDataInView(mockExplorer.Object); + // Act + Frame df = DfDeedle.GetEmailDataInView( + mockExplorer.Object, + etl: _ => (injectedData, injectedColInfo) + ); - // Assert - df.Should().NotBeNull(); - df.RowCount.Should().Be(1); - } - finally - { - DfDeedle.TableEtlInvoker = originalEtl; - } + // Assert + df.Should().NotBeNull(); + df.RowCount.Should().Be(1); } /// @@ -478,7 +474,8 @@ public async Task GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTra explorer.Object, CancellationToken.None, new CancellationTokenSource(), - progress + progress, + timeProvider: new FakeTimeProvider() ); result.RowCount.Should().Be(1); @@ -722,7 +719,7 @@ public void GetDuplicateEntriesByColumn_ReturnsDuplicateValues() } // ---------------------------------------------------------------- - // FromDefaultFolder(Store) — non-null table path (StoreTableEtlInvoker seam) + // FromDefaultFolder(Store) — non-null table path (optional etl parameter) // ---------------------------------------------------------------- [TestMethod] @@ -732,7 +729,7 @@ public void FromDefaultFolder_Store_WithInjectedEtlResult_ReturnsPopulatedFrame( // and mock folder.GetTable() (the COM method on MAPIFolder) to return a mock Table. // GetTable(Store,...) is a static extension method that Moq cannot intercept directly; // the correct seam is the underlying COM interface chain. - // StoreTableEtlInvoker is replaced to supply known data without live COM calls. + // The optional etl parameter supplies known data without live COM calls. var mockColumns = new Mock(MockBehavior.Loose); var mockTable = new Mock(MockBehavior.Loose); mockTable.SetupGet(t => t.Columns).Returns(mockColumns.Object); @@ -759,27 +756,18 @@ public void FromDefaultFolder_Store_WithInjectedEtlResult_ReturnsPopulatedFrame( ["StoreId"] = 5, }; - var originalSeam = DfDeedle.StoreTableEtlInvoker; - DfDeedle.StoreTableEtlInvoker = _ => (injectedData, injectedColInfo); + // Act + Frame df = DfDeedle.FromDefaultFolder( + mockStore.Object, + OlDefaultFolders.olFolderInbox, + Array.Empty(), + Array.Empty(), + etl: _ => (injectedData, injectedColInfo) + ); - try - { - // Act - Frame df = DfDeedle.FromDefaultFolder( - mockStore.Object, - OlDefaultFolders.olFolderInbox, - Array.Empty(), - Array.Empty() - ); - - // Assert: frame is non-null and has data from the injected ETL result. - df.Should().NotBeNull(); - df.RowCount.Should().Be(1); - } - finally - { - DfDeedle.StoreTableEtlInvoker = originalSeam; - } + // Assert: frame is non-null and has data from the injected ETL result. + df.Should().NotBeNull(); + df.RowCount.Should().Be(1); } // ---------------------------------------------------------------- @@ -793,7 +781,7 @@ public void FromDefaultFolder_Stores_FirstStoreHasData_ReturnsNonEmptyFrame() // and mock folder.GetTable() (the COM method on MAPIFolder) to return a mock Table. // GetTable(Store,...) is a static extension method that Moq cannot intercept directly; // the correct seam is the underlying COM interface chain. - // StoreTableEtlInvoker supplies known data with an EntryID column so that + // The optional etl parameter supplies known data with an EntryID column so that // the IndexRowsWith and frame-assembly paths inside the method are exercised. var mockColumns = new Mock(MockBehavior.Loose); var mockTable = new Mock(MockBehavior.Loose); @@ -824,27 +812,18 @@ public void FromDefaultFolder_Stores_FirstStoreHasData_ReturnsNonEmptyFrame() var mockStores = new Mock(MockBehavior.Loose); mockStores.Setup(s => s.GetEnumerator()).Returns(singleStoreList.GetEnumerator()); - var originalSeam = DfDeedle.StoreTableEtlInvoker; - DfDeedle.StoreTableEtlInvoker = _ => (injectedData, injectedColInfo); + // Act + Frame df = DfDeedle.FromDefaultFolder( + mockStores.Object, + OlDefaultFolders.olFolderInbox, + Array.Empty(), + Array.Empty(), + etl: _ => (injectedData, injectedColInfo) + ); - try - { - // Act - Frame df = DfDeedle.FromDefaultFolder( - mockStores.Object, - OlDefaultFolders.olFolderInbox, - Array.Empty(), - Array.Empty() - ); - - // Assert: frame is non-null and contains data from the single store. - df.Should().NotBeNull(); - df.RowCount.Should().Be(1); - } - finally - { - DfDeedle.StoreTableEtlInvoker = originalSeam; - } + // Assert: frame is non-null and contains data from the single store. + df.Should().NotBeNull(); + df.RowCount.Should().Be(1); } private static ProgressTracker CreateProgressTracker() diff --git a/UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs b/UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs index 790426f82..ff5d8d208 100644 --- a/UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs +++ b/UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs @@ -248,6 +248,28 @@ public async Task TryAddValuesAsync_UpdatesExistingValue() dictionary["value"].Should().Be(10); } + /// + /// Locks the surviving cancellation contract after the internal 500 ms window was + /// removed: cancellation is governed solely by the caller's token, so a token that is + /// already cancelled still yields TaskCanceledException and leaves the value untouched. + /// + [TestMethod] + public async Task TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged() + { + // Arrange + var dictionary = new ConcurrentDictionary(); + dictionary["value"] = 8; + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + Func act = () => dictionary.TryAddValuesAsync("value", 2, cts.Token); + + // Assert + await act.Should().ThrowAsync(); + dictionary["value"].Should().Be(8); + } + [TestMethod] public void UpdateOrRemove_RemovesOrUpdatesBasedOnCondition() { diff --git a/UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs b/UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs index 60ad66b4c..7f89c1629 100644 --- a/UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs +++ b/UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs @@ -14,13 +14,10 @@ public class NLogTraceWriter_Test private MockRepository mockRepository; private Mock mockLogger; private Mock mockTraceWriter; - private System.IO.TextWriter originalOut; [TestInitialize] public void TestInitialize() { - this.originalOut = Console.Out; - Console.SetOut(new DebugTextWriter()); this.mockRepository = new MockRepository(MockBehavior.Loose); this.mockLogger = SetupLogger(); this.mockTraceWriter = this.mockRepository.Create(); @@ -50,12 +47,6 @@ internal void PrintLogCall(string loggerName, object message, Exception ex) Console.WriteLine($"Exception: {ex}"); } - [TestCleanup] - public void TestCleanup() - { - Console.SetOut(this.originalOut); - } - [TestMethod] public void Trace_TraceLevel_Error() { diff --git a/UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs b/UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs index 5e0cf1d99..43973cb85 100644 --- a/UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs +++ b/UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs @@ -11,12 +11,6 @@ namespace UtilitiesCS.Test.HelperClasses { - // [DoNotParallelize] — DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput - // captures and restores Console.Out, which is process-wide state. Under the - // class-level parallel scope set in TaskMaster.runsettings, a sibling test - // class's Console.SetOut overrides this class's redirect mid-test, causing - // PrettyPrint's Console.WriteLine output to land in the wrong writer. - [DoNotParallelize] [TestClass] public class PrettyPrint_Tests { @@ -183,7 +177,7 @@ public void ToFormattedTextAndMarkdown_ForTwoDimensionalArray_RenderAllPrimitive } [TestMethod] - public void DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput() + public void DataFramePrettyHelpers_RenderRowsMarkdownAndWriterOutput() { // Arrange var frame = new DataFrame( @@ -191,32 +185,48 @@ public void DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput() new PrimitiveDataFrameColumn("Count", new[] { 1, 2 }) ); var row = frame.Rows[1]; - var originalOut = Console.Out; using var writer = new StringWriter(); - Console.SetOut(writer); - try - { - // Act - var prettyText = frame.PrettyText(); - var prettyRow = row.Pretty(); - var markdown = frame.ToMarkdown(); - frame.PrettyPrint(); - row.PrettyPrint(); - - // Assert - prettyText.Should().Contain("Name"); - prettyText.Should().Contain("alpha"); - prettyRow.Should().Be(" 2"); - markdown.Should().Contain("Name"); - markdown.Should().Contain("alpha"); - writer.ToString().Should().Contain("Name"); - writer.ToString().Should().Contain(" 2"); - } - finally - { - Console.SetOut(originalOut); - } + // Act + var prettyText = frame.PrettyText(); + var prettyRow = row.Pretty(); + var markdown = frame.ToMarkdown(); + frame.PrettyPrint(writer); + row.PrettyPrint(writer); + + // Assert + prettyText.Should().Contain("Name"); + prettyText.Should().Contain("alpha"); + prettyRow.Should().Be(" 2"); + markdown.Should().Contain("Name"); + markdown.Should().Contain("alpha"); + writer.ToString().Should().Contain("Name"); + writer.ToString().Should().Contain(" 2"); + } + + /// + /// Exercises the null-writer default of both PrettyPrint overloads, which resolves to + /// Console.Out and preserves the behaviour every existing caller relies on. + /// + [TestMethod] + public void PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing() + { + // Arrange + var frame = new DataFrame( + new StringDataFrameColumn("Name", new string[] { "alpha", null }), + new PrimitiveDataFrameColumn("Count", new[] { 1, 2 }) + ); + var row = frame.Rows[1]; + + // Act + Assert + FluentActions + .Invoking(() => + { + frame.PrettyPrint(); + row.PrettyPrint(); + }) + .Should() + .NotThrow(); } [TestMethod] diff --git a/UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs b/UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs index c28d715ad..587671542 100644 --- a/UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs @@ -5,13 +5,6 @@ namespace UtilitiesCS.Test.OutlookObjects.FilterDASL { - // PrintTree_WritesIndentedTreeToConsole captures and restores Console.Out, which is - // process-wide state. Under the class-level parallel scope declared by the Parallelize - // attribute at UtilitiesCS.Test/Properties/AssemblyInfo.cs lines 18-21, a sibling test - // class's Console.SetOut overrides this class's redirect mid-test and makes the captured - // output empty. The assembly attribute, not TaskMaster.runsettings, is what takes effect: - // the CI vstest invocation passes no /Settings: argument. - [DoNotParallelize] [TestClass] public class DASLFilterParserTests { @@ -99,24 +92,15 @@ public void Parse_WithInvalidSyntaxThatNeverClosesParenthesis_ReturnsLeafNode() } [TestMethod] - public void PrintTree_WritesIndentedTreeToConsole() + public void PrintTree_WritesIndentedTreeToSuppliedWriter() { // Arrange var parser = new DASLFilterParser(); var tree = parser.Parse("A AND B"); using var writer = new StringWriter(); - var originalOut = Console.Out; - Console.SetOut(writer); - - try - { - // Act - parser.PrintTree(tree, 0); - } - finally - { - Console.SetOut(originalOut); - } + + // Act + parser.PrintTree(tree, 0, writer); // Assert writer.ToString().Should().Contain("AND").And.Contain(" A").And.Contain(" B"); diff --git a/UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs b/UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs new file mode 100644 index 000000000..c2fd86705 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS.Test.TestHelpers; +using Outlook = Microsoft.Office.Interop.Outlook; + +namespace UtilitiesCS.Test.OutlookObjects.Table +{ + /// + /// Clock-driven tests for . The 250 ms per-row + /// deadline is placed under an injected clock, so both the expiry path and the green path + /// are deterministic and neither waits on wall time. + /// + [TestClass] + // Not parallelized: this class drives a real Task.Run gate. + [DoNotParallelize] + public class OlTableExtensionsEtlClockTests + { + private static Mock CreateRowMock( + object[] values, + IDictionary binaryStrings, + IDictionary indexedValues + ) + { + var mockRow = new Mock(); + mockRow.Setup(r => r.GetValues()).Returns(values); + + foreach (var pair in binaryStrings) + { + mockRow.Setup(r => r.BinaryToString(pair.Key)).Returns(pair.Value); + } + + foreach (var pair in indexedValues) + { + mockRow.Setup(r => r[pair.Key]).Returns(pair.Value); + } + + return mockRow; + } + + /// + /// Builds a Table over the supplied rows. The onGetNextRow action is the test-owned gate: + /// blocking inside it keeps the row read from completing, so the ETL hop deadline is the + /// only thing that can end the attempt. + /// + private static Mock CreateTableWithColumns( + string[] columnNames, + System.Action onGetNextRow, + object[,] array, + params Mock[] rows + ) + { + var mockTable = new Mock(); + var mockColumns = new Mock(); + mockTable.Setup(t => t.Columns).Returns(mockColumns.Object); + mockColumns.Setup(c => c.Count).Returns(columnNames.Length); + + for (var index = 0; index < columnNames.Length; index++) + { + var mockColumn = new Mock(); + mockColumn.Setup(c => c.Name).Returns(columnNames[index]); + mockColumns.Setup(c => c[index + 1]).Returns(mockColumn.Object); + } + + mockTable.Setup(t => t.GetRowCount()).Returns(rows.Length); + + var currentRow = 0; + mockTable.Setup(t => t.MoveToStart()).Callback(() => currentRow = 0); + mockTable.Setup(t => t.EndOfTable).Returns(() => currentRow >= rows.Length); + mockTable + .Setup(t => t.GetNextRow()) + .Returns(() => + { + onGetNextRow(); + return rows[currentRow++].Object; + }); + + if (array is not null) + { + mockTable.Setup(t => t.GetArray(It.IsAny())).Returns(array); + } + + return mockTable; + } + + private static Dictionary> CreateConverters() + { + return new Dictionary> + { + { "MessageRecipients", _ => "Converted Recipients" }, + }; + } + + /// + /// Documents the surviving EtlAsync contract on deadline expiry: the TimeoutException is + /// swallowed, a null data array is returned through a null-forgiving suppression, and the + /// supplied token source is cancelled. This is the shape the DfDeedle guard now catches. + /// + [TestMethod] + public async Task EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource() + { + // Arrange + var gate = new ManualResetEventSlim(false); + var recipient = new object(); + var row = CreateRowMock( + new object[] { recipient, "raw-store", "Subject" }, + new Dictionary { { 2, "STORE-ID-101" } }, + new Dictionary { { 1, recipient } } + ); + var mockTable = CreateTableWithColumns( + new[] { "MessageRecipients", "Store", "Subject" }, + gate.Wait, + null, + row + ); + var barrier = new ArmingBarrierTimeProvider(new FakeTimeProvider()); + var tokenSource = new CancellationTokenSource(); + + try + { + // Act + Task<(object[,] data, Dictionary columnInfo)> call = + mockTable.Object.EtlAsync( + CancellationToken.None, + tokenSource, + 0, + null, + CreateConverters(), + barrier + ); + + // The row read is blocked on the gate, so the hop deadline is armed before the + // clock moves. Advancing earlier would arm the timer past an elapsed deadline. + await barrier.Armed; + barrier.Advance(250); + var (data, _) = await call; + + // Assert + data.Should().BeNull(); + tokenSource.IsCancellationRequested.Should().BeTrue(); + } + finally + { + // Release the gate so the orphaned Task.Run body completes. + gate.Set(); + } + } + + /// + /// The deterministic green path: the clock never advances, so the 250 ms deadline cannot + /// fire regardless of host load, and the rows transform normally. + /// + [TestMethod] + public async Task EtlAsync_ClockNeverAdvances_ReturnsTransformedRows() + { + // Arrange: no gate is engaged, so the row read completes immediately. + var recipient = new object(); + var row = CreateRowMock( + new object[] { recipient, "raw-store", "Subject" }, + new Dictionary { { 2, "STORE-ID-102" } }, + new Dictionary { { 1, recipient } } + ); + var mockTable = CreateTableWithColumns( + new[] { "MessageRecipients", "Store", "Subject" }, + () => { }, + null, + row + ); + var tokenSource = new CancellationTokenSource(); + + // Act + var (data, columnInfo) = await mockTable.Object.EtlAsync( + CancellationToken.None, + tokenSource, + 0, + null, + CreateConverters(), + new FakeTimeProvider() + ); + + // Assert + columnInfo["Store"].Should().Be(1); + data[0, 1].Should().Be("STORE-ID-102"); + tokenSource.IsCancellationRequested.Should().BeFalse(); + } + + /// + /// The GetArray branch: with no BinaryToStringFields column and no object converters, + /// EtlAsync snapshots the whole table in one call instead of reading row by row. Its + /// 250 ms deadline is armed on a clock that never advances, so it cannot fire. + /// + [TestMethod] + public async Task EtlAsync_NoBinaryOrObjectFields_UsesGetArrayBranchOnControlledClock() + { + // Arrange + var array = new object[,] + { + { "entry-1", "Subject" }, + }; + var row = CreateRowMock( + new object[] { "entry-1", "Subject" }, + new Dictionary(), + new Dictionary() + ); + var mockTable = CreateTableWithColumns( + new[] { "EntryID", "Subject" }, + () => { }, + array, + row + ); + var tokenSource = new CancellationTokenSource(); + + // Act + var (data, columnInfo) = await mockTable.Object.EtlAsync( + CancellationToken.None, + tokenSource, + 0, + null, + null, + new FakeTimeProvider() + ); + + // Assert + columnInfo["Subject"].Should().Be(1); + data[0, 0].Should().Be("entry-1"); + tokenSource.IsCancellationRequested.Should().BeFalse(); + } + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs b/UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs index ec5eb9834..f6ba7b150 100644 --- a/UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.Extensions.Time.Testing; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using UtilitiesCS.OutlookObjects.Fields; @@ -14,9 +15,9 @@ namespace UtilitiesCS.Test.OutlookObjects.Table { - // EnumerateTable_WritesFormattedOutputAndMovesToStart redirects Console.Out, - // which is process-wide state. Under class-level parallel execution another - // test class can replace the writer mid-test and make the captured output empty. + // The console reason for this attribute was removed by the TextWriter seam under #811. + // It is retained because this class has not been soaked under class-level parallelism + // and ten of its tests drive the 2000 ms GetTableInViewAsync window. [DoNotParallelize] [TestClass] public class OlTableExtensions_Tests @@ -957,10 +958,6 @@ public async Task EtlAsync_WithBinaryAndObjectFieldsAndProgress_ReturnsTransform null, row ); - // EtlAsync computes its TimeoutAfter budget as 250 ms * GetRowCount(). - // Override to a large value so the timeout cannot fire under test-host - // contention; iteration is driven by EndOfTable/GetNextRow, not GetRowCount. - mockTable.Setup(t => t.GetRowCount()).Returns(120); var converters = new Dictionary> { { "MessageRecipients", _ => "Converted Async Recipients" }, @@ -973,7 +970,8 @@ public async Task EtlAsync_WithBinaryAndObjectFieldsAndProgress_ReturnsTransform tokenSource, 0, progress, - converters + converters, + timeProvider: new FakeTimeProvider() ); columnInfo["Store"].Should().Be(1); @@ -1621,7 +1619,7 @@ public void GetColumnHeaders_SchemaName_MapsToFieldName() } [TestMethod] - public void EnumerateTable_WritesFormattedOutputAndMovesToStart() + public void EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart() { var schemaName = MAPIFields.FieldToSchema["Store"]; var array = new object[,] @@ -1633,21 +1631,14 @@ public void EnumerateTable_WritesFormattedOutputAndMovesToStart() array ); var output = new StringWriter(); - var original = Console.Out; - try - { - Console.SetOut(output); - mockTable.Object.EnumerateTable(); - } - finally - { - Console.SetOut(original); - } + mockTable.Object.EnumerateTable(output); output.ToString().Should().Contain("Store"); output.ToString().Should().Contain("Subject"); output.ToString().Should().Contain("STORE-ID-009"); + // The null-writer path resolving to Console.Out; this file is at its line ceiling. + FluentActions.Invoking(() => mockTable.Object.EnumerateTable()).Should().NotThrow(); mockTable.Verify(t => t.MoveToStart(), Times.AtLeastOnce); } diff --git a/UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs b/UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs index caae5708f..0052b7341 100644 --- a/UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs +++ b/UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs @@ -3,16 +3,10 @@ using System.Reflection; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS; namespace UtilitiesCS.Test.ReusableTypeClasses { - // Main_RunsSampleScenarioWithoutThrowing captures and restores Console.Out, which is - // process-wide state. Under the class-level parallel scope declared by the Parallelize - // attribute at UtilitiesCS.Test/Properties/AssemblyInfo.cs lines 18-21, a sibling test - // class's Console.SetOut overrides this class's redirect mid-test and makes the captured - // output empty. The assembly attribute, not TaskMaster.runsettings, is what takes effect: - // the CI vstest invocation passes no /Settings: argument. - [DoNotParallelize] [TestClass] public class StackGeek_Tests { @@ -143,24 +137,33 @@ public void DeleteMiddle_WithSingleElement_ShouldNotThrow() helper.GetMiddle(stack).Should().BeNull(); } + /// + /// Exercises the null-writer default: Main forwards to Run(Console.Out), so this test + /// asserts only that the sample scenario completes, with no Console.Out capture. + /// [TestMethod] public void Main_RunsSampleScenarioWithoutThrowing() { // Arrange var helper = new StackGeekReflectionHelper(); - var originalOut = Console.Out; - using var writer = new StringWriter(); - Console.SetOut(writer); - try - { - // Act - helper.InvokeMain(Array.Empty()); - } - finally - { - Console.SetOut(originalOut); - } + // Act + Assert + FluentActions + .Invoking(() => helper.InvokeMain(Array.Empty())) + .Should() + .NotThrow(); + } + + /// + /// The TextWriter seam: the scenario writes to a test-owned writer, so the assertion no + /// longer depends on process-wide Console.Out and cannot be broken by a sibling class. + /// + [TestMethod] + public void Run_WritesScenarioToSuppliedWriter() + { + // Arrange + Act + using var writer = new StringWriter(); + GFG.Run(writer); // Assert writer.ToString().Should().Contain("Middle Element :"); diff --git a/UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs b/UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs new file mode 100644 index 000000000..df1a13c89 --- /dev/null +++ b/UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Time.Testing; + +namespace UtilitiesCS.Test.TestHelpers +{ + /// + /// A forwarding every member to an inner + /// unchanged and, after forwarding CreateTimer, + /// completing a signal that the production loop has armed its next deadline. + /// + /// + /// Forwarding keeps timer ownership with the inner provider. Consecutive Advance + /// calls are prohibited: each deadline is armed only after the previous proxy faults, so + /// advancing past a deadline the loop has not created hangs the test. TrySetResult + /// is used because the loop can arm one more timer than a test drives. + /// + internal sealed class ArmingBarrierTimeProvider : TimeProvider + { + private readonly FakeTimeProvider _inner; + private volatile TaskCompletionSource _armed = NewSignal(); + + internal ArmingBarrierTimeProvider(FakeTimeProvider inner) => _inner = inner; + + internal Task Armed => _armed.Task; + + internal void ReArm() => _armed = NewSignal(); + + internal void Advance(int ms) => _inner.Advance(TimeSpan.FromMilliseconds(ms)); + + public override DateTimeOffset GetUtcNow() => _inner.GetUtcNow(); + + public override long GetTimestamp() => _inner.GetTimestamp(); + + public override TimeZoneInfo LocalTimeZone => _inner.LocalTimeZone; + public override long TimestampFrequency => _inner.TimestampFrequency; + + public override ITimer CreateTimer( + TimerCallback callback, + object state, + TimeSpan dueTime, + TimeSpan period + ) + { + var timer = _inner.CreateTimer(callback, state, dueTime, period); + _armed.TrySetResult(true); + return timer; + } + + // Continuations run asynchronously so a signal never resumes a test inline. + internal static TaskCompletionSource NewSignal() => + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } +} diff --git a/UtilitiesCS.Test/UtilitiesCS.Test.csproj b/UtilitiesCS.Test/UtilitiesCS.Test.csproj index 5ce3b0e86..be7fea8cf 100644 --- a/UtilitiesCS.Test/UtilitiesCS.Test.csproj +++ b/UtilitiesCS.Test/UtilitiesCS.Test.csproj @@ -71,6 +71,7 @@ + @@ -189,6 +190,7 @@ + @@ -539,6 +541,7 @@ + diff --git a/UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs b/UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs index 564d72049..e05893750 100644 --- a/UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs +++ b/UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs @@ -126,7 +126,8 @@ IEnumerable objIndices Store store, OlDefaultFolders folderEnum, string[] removeColumns, - string[] addColumns + string[] addColumns, + Func columnInfo)>? etl = null ) { var table = store.GetTable( @@ -140,7 +141,7 @@ string[] addColumns return null; } - (var data, var columnInfo) = StoreTableEtlInvoker(table); + (var data, var columnInfo) = (etl ?? DefaultTableEtl)(table); Frame? df = FromArray2D(data: data, columnInfo); @@ -151,7 +152,8 @@ public static Frame FromDefaultFolder( Stores stores, OlDefaultFolders folderEnum, string[] removeColumns, - string[] addColumns + string[] addColumns, + Func columnInfo)>? etl = null ) { Frame? df = null; @@ -161,7 +163,8 @@ string[] addColumns store: store, folderEnum: folderEnum, removeColumns: removeColumns, - addColumns: addColumns + addColumns: addColumns, + etl: etl ); if ( diff --git a/UtilitiesCS/Extensions/DfDeedle.cs b/UtilitiesCS/Extensions/DfDeedle.cs index f83e52cc4..b5adec1e5 100644 --- a/UtilitiesCS/Extensions/DfDeedle.cs +++ b/UtilitiesCS/Extensions/DfDeedle.cs @@ -60,30 +60,19 @@ internal static Func< > MessageBoxInvoker = System.Windows.Forms.MessageBox.Show; /// - /// Testability seam for the extension method - /// that converts an Outlook Table to a 2-D data array. - /// Tests replace this delegate to supply pre-built data without a live COM Table. - /// Uses as the parameter type to avoid CS1769 (embedded interop - /// types cannot be used as generic type arguments across assembly boundaries). + /// The production ETL over an Outlook Table, used whenever no etl argument is + /// supplied. Uses as the parameter type to avoid CS1769 (embedded + /// interop types cannot be used as generic type arguments across assembly boundaries). /// - internal static Func< - object, - (object[,] data, Dictionary columnInfo) - > TableEtlInvoker = t => ((Outlook.Table)t).ETL(); - - /// - /// Testability seam for the extension method - /// as used inside . - /// Tests replace this delegate to supply pre-built data without a live COM Table. - /// Uses as the parameter type to avoid CS1769 (embedded interop - /// types cannot be used as generic type arguments across assembly boundaries). - /// - internal static Func< + private static readonly Func< object, (object[,] data, Dictionary columnInfo) - > StoreTableEtlInvoker = t => ((Outlook.Table)t).ETL(); + > DefaultTableEtl = t => ((Outlook.Table)t).ETL(); - public static Frame GetEmailDataInView(Explorer activeExplorer) + public static Frame GetEmailDataInView( + Explorer activeExplorer, + Func columnInfo)>? etl = null + ) { Outlook.Table table = activeExplorer.GetTableInView(); var currentFolder = activeExplorer.CurrentFolder; @@ -91,7 +80,7 @@ public static Frame GetEmailDataInView(Explorer activeExplorer) AddQfcColumns(table, currentFolder); - (object[,] data, Dictionary columnInfo) = TableEtlInvoker(table); + (object[,] data, Dictionary columnInfo) = (etl ?? DefaultTableEtl)(table); // Closes the second set of unchecked dictionary reads, the ones GetEmailDataFromTable // performs on this synchronous path. The asynchronous path never reaches them, so its @@ -140,7 +129,10 @@ public static async Task> GetEmailDataInViewAsync( Explorer activeExplorer, CancellationToken token, CancellationTokenSource tokenSource, - ProgressTracker progress + ProgressTracker progress, + // A null timeProvider resolves to the system clock, so production timing is unchanged. + // Tests inject a FakeTimeProvider to drive every deadline on this path deterministically. + TimeProvider? timeProvider = null ) { token.ThrowIfCancellationRequested(); @@ -173,7 +165,7 @@ ProgressTracker progress //logger.Debug($"{DateTime.Now.ToString("mm:ss.fff")} Calling {nameof(AddQfcColumnsAsync)} ..."); // currentFolder is the live Explorer's CurrentFolder (non-null); the defensive // ?.Name in the preceding log line set its flow state to maybe-null. - await AddQfcColumnsAsync(table, currentFolder!, token, 0); + await AddQfcColumnsAsync(table, currentFolder!, token, 0, timeProvider: timeProvider); //logger.Debug($"{DateTime.Now.ToString("mm:ss.fff")} Calling {nameof(OlTableExtensions.EtlAsync)} ..."); var etlStopwatch = Stopwatch.StartNew(); @@ -181,8 +173,21 @@ ProgressTracker progress token, tokenSource, 0, - progress.Increment(2).SpawnChild(96) + progress.Increment(2).SpawnChild(96), + timeProvider: timeProvider ); + + // EtlAsync swallows its TimeoutException and returns a null data array through a + // null-forgiving suppression, so this is the first point the failure can be named. + if (tableSnapshot.data is null) + { + throw new InvalidOperationException( + $"The table snapshot for folder '{folderName}' was not produced: the table ETL " + + "timed out or was cancelled before returning any rows, so the email data " + + "frame cannot be built for this folder." + ); + } + LogDfTiming( "GetEmailDataInViewAsync table snapshot ready | table snapshot", $"rowCount={tableSnapshot.Item1.GetLength(0)}; columnCount={tableSnapshot.Item1.GetLength(1)}; etlElapsedMs={etlStopwatch.ElapsedMilliseconds}" @@ -205,7 +210,7 @@ ProgressTracker progress () => Email2dArrayToDf(storeID, tableSnapshot.Item1, tableSnapshot.Item2), token ) - .TimeoutAfter(1000, 2); + .TimeoutAfter(1000, timeProvider); LogDfTiming( "GetEmailDataInViewAsync dataframe transform complete | dataframe transform", $"rowCount={df.RowCount}; columnCount={df.ColumnCount}; elapsedMs={dataframeStopwatch.ElapsedMilliseconds}; totalElapsedMs={getEmailDataStopwatch.ElapsedMilliseconds}" diff --git a/UtilitiesCS/Extensions/DictionaryExtensions.cs b/UtilitiesCS/Extensions/DictionaryExtensions.cs index 07eb20bc7..fc6366a0c 100644 --- a/UtilitiesCS/Extensions/DictionaryExtensions.cs +++ b/UtilitiesCS/Extensions/DictionaryExtensions.cs @@ -173,10 +173,9 @@ public static async Task TryAddValuesAsync( CancellationToken token ) { - var linkedTS = CancellationTokenSource.CreateLinkedTokenSource(token); - linkedTS.CancelAfter(500); - - return await Task.Run(() => dictionary.TryAddValues(key, value), linkedTS.Token); + // TryAddValues is a bounded compare-and-swap loop that performs no I/O and cannot + // hang, so cancellation is governed solely by the caller's token. + return await Task.Run(() => dictionary.TryAddValues(key, value), token); } public static Enums.DictionaryResult UpdateOrRemove( diff --git a/UtilitiesCS/HelperClasses/PrettyPrint.cs b/UtilitiesCS/HelperClasses/PrettyPrint.cs index e711d963d..805cbd57d 100644 --- a/UtilitiesCS/HelperClasses/PrettyPrint.cs +++ b/UtilitiesCS/HelperClasses/PrettyPrint.cs @@ -1,30 +1,30 @@ #nullable enable using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text; -using System.Threading.Tasks; namespace UtilitiesCS { using System.Data; using System.Diagnostics; - using System.Text; using System.Text.RegularExpressions; using System.Windows.Forms; using System.Windows.Input; using Microsoft.Data.Analysis; using Microsoft.Office.Interop.Outlook; - using Svg; /// /// Class written to transform Dataframe objects for printing /// public static class PrettyPrinters { - public static void PrettyPrint(this DataFrame df) => Console.WriteLine(PrettyText(df)); + public static void PrettyPrint(this DataFrame df, TextWriter? writer = null) => + (writer ?? Console.Out).WriteLine(PrettyText(df)); - public static void PrettyPrint(this DataFrameRow row) => Console.WriteLine(Pretty(row)); + public static void PrettyPrint(this DataFrameRow row, TextWriter? writer = null) => + (writer ?? Console.Out).WriteLine(Pretty(row)); public static string PrettyText(this DataFrame df) => ToStringArray2D(df).ToFormattedText(); diff --git a/UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs b/UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs index 1cda7a3d1..e54b90273 100644 --- a/UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs +++ b/UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.Collections.Generic; +using System.IO; using System.Text.RegularExpressions; using UtilitiesCS.ReusableTypeClasses; @@ -94,12 +95,12 @@ private int FindOperatorIndex(string expression, string operatorStr) // return match.Success ? match.Index : -1; //} - public void PrintTree(TreeNode node, int level) + public void PrintTree(TreeNode node, int level, TextWriter? writer = null) { - Console.WriteLine(new string(' ', level * 2) + node.Value); + (writer ?? Console.Out).WriteLine(new string(' ', level * 2) + node.Value); foreach (var child in node.Children) { - PrintTree(child, level + 1); + PrintTree(child, level + 1, writer); } } diff --git a/UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs b/UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs index 246ec4c2a..2db430c91 100644 --- a/UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs +++ b/UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs @@ -69,7 +69,10 @@ objectConverters is not null CancellationTokenSource tokenSource, int counter, ProgressTracker? progress, - Dictionary>? objectConverters = null + Dictionary>? objectConverters = null, + // A null timeProvider resolves to the system clock, so production timing is unchanged. + // Tests inject a FakeTimeProvider to drive the deadline without a wall-clock wait. + TimeProvider? timeProvider = null ) { token.ThrowIfCancellationRequested(); @@ -79,7 +82,6 @@ objectConverters is not null var rowCount = table.GetRowCount(); int milliseconds = 250 * rowCount; - var attempts = 3; object[,]? data = null; var columnDictionary = table.GetColumnDictionary(); @@ -101,7 +103,7 @@ objectConverters is not null columnDictionary, token, milliseconds, - attempts, + timeProvider, progress ); } @@ -111,13 +113,13 @@ objectConverters is not null () => table?.GetArray(table.GetRowCount()) as object[,], token ) - .TimeoutAfter(milliseconds, attempts); + .TimeoutAfter(milliseconds, timeProvider); } } catch (TimeoutException) { logger.Error( - $"{DateTime.Now.ToString("mm:ss.fff")} {nameof(ETL)} timed out {attempts} times with a timeout of {milliseconds} milliseconds. Canceling" + $"{nameof(EtlAsync)} timed out with a timeout of {milliseconds} milliseconds. Canceling" ); tokenSource.Cancel(); } @@ -232,7 +234,7 @@ private static async Task EtlByRowAsync( Dictionary columnDictionary, CancellationToken token, int timeout, - int attempts, + TimeProvider? timeProvider, ProgressTracker? progress = null ) { @@ -242,7 +244,7 @@ private static async Task EtlByRowAsync( (var objFields, var objIndices) = GetObjectFields(objectConverters, columnDictionary); var rows = await Task.Run(() => table.CastToRowArray(progress?.SpawnChild(65)), token) - .TimeoutAfter(timeout, attempts); + .TimeoutAfter(timeout, timeProvider); token.ThrowIfCancellationRequested(); var jagged = await Task.Run( @@ -256,7 +258,7 @@ private static async Task EtlByRowAsync( ), token ) - .TimeoutAfter(timeout, attempts); + .TimeoutAfter(timeout, timeProvider); var data = jagged.To2D(); return data; diff --git a/UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs b/UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs index 34f1f74d8..ef605ced3 100644 --- a/UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs +++ b/UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading; @@ -378,8 +379,9 @@ public static string[] GetColumnHeaders(this Outlook.Table table) return headers; } - public static void EnumerateTable(this Outlook.Table table) + public static void EnumerateTable(this Outlook.Table table, TextWriter? writer = null) { + var target = writer ?? Console.Out; int columnCount = table.Columns.Count; int[] charSpacing = Enumerable.Repeat(20, columnCount).ToArray(); Justify[] justification = Enumerable.Repeat(Justify.Left, columnCount).ToArray(); @@ -420,9 +422,9 @@ public static void EnumerateTable(this Outlook.Table table) rows.Add(lineDivider); string output = string.Join("\n", rows.ToArray()); - Console.WriteLine(""); - Console.WriteLine(""); - Console.WriteLine(output); + target.WriteLine(""); + target.WriteLine(""); + target.WriteLine(output); table.MoveToStart(); } diff --git a/UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs b/UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs index ee42b9c5f..6664bf45c 100644 --- a/UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs +++ b/UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs @@ -171,6 +171,13 @@ void deleteMiddle(myStack ms) // Driver code public static void Main(String[] args) + { + Run(Console.Out); + } + + // The sample scenario, written to a supplied writer so a test can assert on the output + // without redirecting the process-wide Console.Out. + public static void Run(System.IO.TextWriter writer) { GFG ob = new GFG(); myStack ms = ob.createMyStack(); @@ -184,11 +191,11 @@ public static void Main(String[] args) ob.push(ms, 88); ob.push(ms, 99); - Console.WriteLine("Popped : " + ob.pop(ms)); - Console.WriteLine("Popped : " + ob.pop(ms)); - Console.WriteLine("Middle Element : " + ob.findMiddle(ms)); + writer.WriteLine("Popped : " + ob.pop(ms)); + writer.WriteLine("Popped : " + ob.pop(ms)); + writer.WriteLine("Middle Element : " + ob.findMiddle(ms)); ob.deleteMiddle(ms); - Console.WriteLine("New Middle Element : " + ob.findMiddle(ms)); + writer.WriteLine("New Middle Element : " + ob.findMiddle(ms)); } } diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/code-review.2026-09-08T11-30.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/code-review.2026-09-08T11-30.md new file mode 100644 index 000000000..93120c00f --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/code-review.2026-09-08T11-30.md @@ -0,0 +1,315 @@ +# Code Review — utilitiescs-test-determinism (Issue #811) + +- Date: 2026-09-08T11-30 +- Branch: `bug/utilitiescs-test-determinism-780-803-594-811` +- Head: `3805ca89f0c72e2bb448726695f18f420bd260f7` +- Base: `bb1c7d4b60f7b782227956f36859314d5c47bb03` +- Source under review: 20 changed `.cs` / `.csproj` files; 48 further documentation and evidence + files reviewed by direct reading + +Blocking findings in this artifact: **0**. (The single blocking finding for this branch is F-1 in +`policy-audit.2026-09-08T11-30.md`; it is a process gap, not a code defect.) + +## 1. Overall Assessment + +The change is well-scoped and the technique is correct for the problem class. Each of the three +defects is treated at the level where the nondeterminism originates rather than suppressed at the +test level: a guard with no consumer is deleted, wall-clock deadlines that must survive in +production are placed under an injectable `TimeProvider`, and a process-wide output dependency is +converted into an explicit parameter. Notably, the change removes an existing serialization stopgap +and an existing timing tolerance rather than adding new ones, which is the direction the repository +policy asks for. + +The production risk profile is low. Every new parameter is optional and trailing; every default +reproduces prior behavior exactly (`null` `TimeProvider` resolves to the system clock, `null` +`TextWriter` resolves to `Console.Out`, `null` `etl` resolves to the extracted +`DefaultTableEtl`). The one intentional behavior change — an `InvalidOperationException` replacing +a `NullReferenceException` one statement later — is a strict diagnostic improvement on a path that +already failed. + +## 2. Design and Architecture + +### 2.1 Deleting the `TryAddValuesAsync` window — correct + +`DictionaryExtensions.cs` now passes the caller's token straight to `Task.Run`. This reviewer +verified the two load-bearing claims independently rather than accepting them: + +- `TryAddValuesAsync` has zero production call sites. A repository-wide search over `*.cs` returns + exactly four hits: the definition at `DictionaryExtensions.cs:169` and three references in + `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs` (two test method names and one + invocation). No production caller can be affected. +- The token passed to `Task.Run` can only cancel work that has not started, so a window that + cancels after 500 ms could only ever convert scheduling latency into a spurious + `TaskCanceledException`. Deleting it removes a failure mode without removing a guarantee. + +The change also removes a per-call undisposed linked `CancellationTokenSource` and its timer. The +replacement comment states the invariant (bounded compare-and-swap loop, no I/O, cannot hang) +rather than restating the code, which is the correct comment style. + +### 2.2 `TimeProvider` seams — used substantively, not decoratively + +This was checked specifically. The seams are load-bearing, not ornamental: + +- `OlTableExtensions.Etl.cs` converts three `TimeoutAfter(ms, attempts)` calls to + `TimeoutAfter(ms, timeProvider)`. The `attempts` local is deleted rather than left dangling, so + the inert retry parameter is genuinely gone from this path. +- The seam is exercised by real behavior change in tests: `EtlAsync_DeadlineExpires_...` advances a + fake clock by exactly 250 ms and observes the `catch (TimeoutException)` block execute, driving + the `EtlAsync` method-span line rate from 0.7000 to 0.9796. A decorative seam would not have + moved coverage into a previously unreachable catch block. +- `ArmingBarrierTimeProvider` is a genuine correctness requirement, not convenience. + `FakeTimeProvider` arms timers relative to its current time, so advancing before the production + code creates its timer arms that timer past an already-elapsed deadline and the awaiting test + hangs. The barrier forwards `CreateTimer` to the inner provider and completes a + `TaskCompletionSource` afterwards, so the test can `await barrier.Armed` before calling + `Advance`. `TaskCreationOptions.RunContinuationsAsynchronously` is correct here: it prevents a + timer-creating production thread from being hijacked to run the test continuation inline. + `TrySetResult` rather than `SetResult` is correct because the production loop may arm one more + timer than the test drives. +- `DfDeedleEtlTimeoutTests` demonstrates the mechanism is understood rather than copied: it uses + two independent test-owned gates (`gateA` inside `Columns.Add("SentOn")`, `gateB` inside + `GetNextRow`) plus an explicit `ReArm()` between them, so the identity of each armed timer is + determined by the test rather than by a thread-pool race. Without that, the number of `Armed` + signals preceding the ETL deadline would itself be nondeterministic. + +Both gates are released in `finally`, so no orphaned `Task.Run` body outlives the test. That +matters in a `[DoNotParallelize]`-free assembly; leaked blocked pool threads would degrade +subsequent classes. + +### 2.3 Delegate parameters replacing mutable statics — correct, and it narrows the public surface + +Replacing `internal static Func<...> TableEtlInvoker` and `StoreTableEtlInvoker` with an optional +`etl` parameter and a `private static readonly DefaultTableEtl` is the right call. It is +preference 2 in the `.claude/rules/csharp.md` DI-seam order (injectable delegate for a single call +path), the default remains safe and deterministic, and it converts two mutable process-wide fields +into one immutable private one. The `Stores` overload correctly forwards `etl` to the `Store` +overload, so the injected delegate reaches the inner call. + +Constructor injection — preference 1 — is unavailable because all touched members are `static` +extension or factory methods. A trailing optional parameter is the smallest seam that works. This +is a correct application of the rule rather than a shortcut around it. + +### 2.4 The null-snapshot guard — correctly placed + +The guard sits between the `EtlAsync` call and the `LogDfTiming` statement that dereferences +`tableSnapshot.Item1`, and therefore ahead of the pre-existing `ValidateRequiredEmailColumns` +guard, which inspects `Item2` only and would not have caught this. The message names the folder and +states the mechanism ("the table ETL timed out or was cancelled before returning any rows"), which +satisfies the "fail fast and explicitly" requirement and gives the `QfcDatamodel.FrameBuilding.cs` +boundary something attributable to log. + +One observation, not a defect: the guard tests `tableSnapshot.data is null` while the two lines +below still read `tableSnapshot.Item1` / `.Item2`. Mixing the named and positional forms of the +same tuple within four lines is slightly inconsistent. The positional reads are pre-existing lines +and switching them would have added diff noise, so the choice is defensible, but a future edit +should unify on the named form. + +### 2.5 `TextWriter` seams — correct, with one asymmetry + +`PrintTree`, both `PrettyPrint` overloads, `EnumerateTable` and the new `GFG.Run` all take the +writer as a parameter and resolve `null` to `Console.Out`. `EnumerateTable` hoists the resolution +into a local `target` once rather than repeating `(writer ?? Console.Out)` three times, which is +the better form; `PrintTree` repeats the expression once per call and also forwards the original +nullable `writer` on recursion, which is correct (resolving at each level would be equivalent but +allocates nothing either way). The asymmetry is cosmetic. + +`GFG.Main` keeping its `String[] args` parameter while delegating to `Run(Console.Out)` preserves +the existing entry-point shape. Reasonable. + +## 3. Test Quality + +### 3.1 Strengths + +- Assertions are specific. `ThrowAsync().WithMessage("*Inbox*")` will + fail if the code regresses to `NullReferenceException` or drops the folder name, which is exactly + what a generic `Should().Throw()` would have missed. +- `MockBehavior.Strict` is used on `Row`, `Table`, `Column`, `TableView` and `Explorer` in + `DfDeedleEtlTimeoutTests`, so an unanticipated production call fails the test rather than + returning a silent default. +- `SilentProgressTracker` overrides all three `Report` members to no-ops, removing progress + reporting as a timing or output variable. This is a deliberate isolation choice, not incidental. +- The green-path tests use an un-advanced `FakeTimeProvider` rather than a widened deadline. This is + the distinction AC5 turns on and the change gets it right: the deadline is not made larger, it is + made unreachable by a clock the test owns. +- The retired `Returns(120)` tolerance is verified gone from the final tree, not merely absent from + the added lines. Checking the final state as well as the diff is the correct verification shape. + +### 3.2 Findings + +**C-1 (Minor).** `OlTableExtensions_Tests.EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart` +performs two Acts: it asserts on the supplied-writer path, then invokes the null-writer path with +`FluentActions.Invoking(...).Should().NotThrow()` before the final `Verify`. The in-code comment +states the reason ("this file is at its line ceiling"). This trades test isolation for compliance +with the 500-line cap on an already-1846-line file. The trade is understandable but the better +resolution is a small new test file, which would satisfy both constraints. Recommend as follow-up. + +**C-2 (Minor).** `Main_RunsSampleScenarioWithoutThrowing` is now a bare `NotThrow` assertion. It +does still cover the `Main` to `Run(Console.Out)` default branch, and the substantive assertions +moved to `Run_WritesScenarioToSuppliedWriter`, so the pair is complete. But the surviving test's +name promises "runs sample scenario" while asserting only absence of an exception. The doc comment +above it states this accurately, so the intent is documented; the name is slightly stronger than +the assertion. + +**C-3 (Minor).** `PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing` and the null-writer half +of C-1 write to the real `Console.Out` during a now-parallel-eligible run. This is harmless because +after this change no test captures `Console.Out` — but that safety is a property of the whole +assembly, not of these tests, and nothing enforces it. If a future test reintroduces a +`Console.Out` capture, these two null-writer paths become new noise sources. A one-line note in the +class header of each would make the dependency explicit. + +**C-4 (Observation).** `DfDeedleEtlTimeoutTests` and `OlTableExtensionsEtlClockTests` both carry +`[DoNotParallelize]` with a stated reason ("drives a real `Task.Run` gate"). That is the correct +default for gate-driving classes and it is consistent with the pre-existing +`DfDeedleQfcColumnTimeoutTests`. Net effect on the serialized set: minus three classes (attributes +removed) plus two classes (new gate-driving classes), so the serialized set shrinks by one. + +**C-5 (Observation).** `DfDeedle_COM_Tests` still swaps `DfDeedle.MessageBoxInvoker` in four tests. +The class header now asserts it "has no reader on any parallel-phase path". This reviewer verified +that claim: the only production readers are `DfDeedle.QfcColumns.cs:27,170,194`, reached by exactly +three test classes, of which two are `[DoNotParallelize]` and the third is `DfDeedle_COM_Tests` +itself (serialised internally by `ClassLevel` scope). The claim holds today. It is not enforced by +any test or analyzer, and it rests on the MSTest phase-ordering assumption that `spec.md` Risk 3 +explicitly labels documentation-sourced and not run-verified — the same assumption the AC3 seam +work was intended to stop relying on. Recommend the class header state the dependency ("this holds +only while both reader classes remain `[DoNotParallelize]`") so a future edit cannot silently break +it. + +## 4. Directed Assessment: the three removed `[DoNotParallelize]` attributes + +The reviewer was asked to reach an independent verdict on three specific questions. The findings +below are this reviewer's own; they do not adopt the executor's classification. + +### 4.1 Is removing the three attributes a legitimate consequence of AC3, or unnecessary widening? + +**Legitimate, and required by the criterion's own scope definition.** + +`spec.md` line 55 defines the AC3 work as: "introduce a `TextWriter` seam on the four production +members whose output is captured by tests, convert those tests to their own writer, **and remove the +`[DoNotParallelize]` serialization stopgap that currently suppresses the race**." The removal is not +an incidental side effect; it is the deliverable. `spec.md` line 62 gives the reason: with the +attribute in place, the AC4 gate would exercise the stopgap rather than the seam, and the seam would +be unproven. That reasoning is sound. A fix that leaves the suppressant in place is untestable by +construction. + +Three of four attributes were removed. The fourth, on `OlTableExtensions_Tests`, was deliberately +retained under the conservative fallback that `spec.md` Mitigation 4 pre-authorised, and its comment +was rewritten to stop claiming the console as its reason ("The console reason for this attribute was +removed by the `TextWriter` seam under #811. It is retained because this class has not been soaked +under class-level parallelism and ten of its tests drive the 2000 ms `GetTableInViewAsync` window"). +That is the correct judgment: the class is 1846 lines of COM mocks with a documented untested +parallel-safety profile, and the comment now states a true reason rather than a stale one. Retaining +it is not an inconsistency; it is the risk-weighted subset. + +Scope check: each of the three removals is confined to the file that owned the captured output, and +each is paired with the seam conversion in the same file. No unrelated class lost an attribute. This +is not widening. + +### 4.2 Can raising parallel density reasonably be judged to have increased the surfacing probability of the `ILGlobals` race? + +**No — not on any evidence available, and the mechanism argues against it. The claim is not +supportable in either direction beyond "scheduling was perturbed".** + +The reasoning, which this reviewer derived rather than inherited: + +1. **The instantaneous concurrency is capped by the worker count, not by the class count.** + `AssemblyInfo.cs:18-21` sets `Workers = 0`, which resolved to 24 on the run host. During the + parallel phase, at most 24 classes execute at once regardless of how many are eligible. + +2. **The eligible pool is already an order of magnitude larger than the cap.** A search for + `[TestClass]` across `UtilitiesCS.Test` returns more than 400 files, against roughly 35 classes + carrying `[DoNotParallelize]`. The parallel phase is worker-saturated throughout; it is not + starved for runnable classes. Adding three more classes to a pool of ~400 does not raise the + number of classes executing concurrently, because that number was already pinned at 24. + +3. **What actually changes is the dispatch order and the parallel-phase duration.** Three short, + purely in-memory classes (a stack sample, a DataFrame pretty-printer, a DASL string parser) move + from the sequential tail into the saturated pool. Their combined runtime is a negligible fraction + of a 51-to-74-second run over 7162 tests. The perturbation to when `ILGlobals_Tests` and + `MethodBodyReader_Tests` happen to be co-scheduled is real but undirected: it can as easily + reduce the overlap as increase it. There is no argument, and no measurement in the evidence, that + establishes a direction. + +4. **The race's participants and window are untouched.** Both racing classes were parallel-eligible + before this change and are parallel-eligible after. Neither is in the write set. Neither gained + nor lost an attribute. The vulnerable window — the interval in `LoadOpCodes()` between the + reassignment at line 123 and the completion of the fill loop at line 137 — is byte-identical. + +5. **Counter-consideration, stated fairly.** The three moved classes do consume worker slots they + previously did not, which marginally extends the saturated window. If one insists on a + directional claim, that is the only mechanism available, and its magnitude is on the order of + three short classes out of more than four hundred — well under one percent, against a race that + was already surfacing at an unquantified base rate. The evidence does not measure the base rate: + the two baseline runs (P0-T10, P0-T12) did not exhibit it, but two single runs of an + intermittent failure establish nothing about its frequency. Attributing run 7 to this change is + therefore speculation, and the executor's own hedged wording ("can therefore alter the + probability that any latent race surfaces") is the most that the evidence supports. + +**Verdict: the AC4 failure is wholly attributable to a pre-existing defect. This change did not +create it, did not make its participants newly concurrent, and did not measurably increase its +exposure. What this change did do is bring the defect to light by running the suite ten times, +which is the gate working as designed.** + +### 4.3 Blocking or Non-blocking for the AC4 shortfall? + +**Non-blocking**, for the following reasons, weighed against the strongest contrary argument. + +The strongest argument for Blocking is that the item's stated purpose (`spec.md` Context) is "so the +required `mstest-coverage` check stops failing on unrelated pull requests", and with the `ILGlobals` +race unfixed the check can still fail intermittently. On that reading the business objective is not +achieved and the branch should not merge. + +That argument does not survive scrutiny for three reasons: + +1. **The branch is a strict improvement, and blocking it makes the stated problem worse, not + better.** Three failure modes are removed with strong evidence — 130 of 130 tracked assertions + `Passed` across ten runs, including both intermittent-failure sentinels. Holding the fix hostage + to a fourth, unrelated defect leaves four failure modes in `main` instead of one. + +2. **Fixing `ILGlobals` here would violate the repository's own bugfix discipline.** CLAUDE.md's + Bugfix Workflow requires the minimal targeted fix and states: "If you uncover deeper design + problems, open a new issue instead of widening scope." The `ILGlobals` fix touches two production + files and two test classes that are outside the declared 20-path write set, and doing it properly + would need its own RED-first regression test for a race that is not deterministically + reproducible. That is a separate item. + +3. **The shortfall is reported honestly and no re-run-until-green was attempted.** AC4 is left + unchecked in `spec.md`, the ten-run artifact states "AC4 IS NOT SATISFIED" in its verdict line, + and the issue-update artifact records 4 of 5 delivered against a plan that predicted 5 of 5, + explicitly refusing to write the predicted value. Given that AC4 exists precisely to stop + re-run-until-green, an executor that re-ran until green would deserve a blocking finding; one + that reported the failure does not. + +**What is Blocking is the disposal of the finding, not the finding itself.** The `ILGlobals` race +exists only as prose inside a feature folder that is archived at merge, with no +`docs/features/potential/` entry and no issue. The executor's stated reason — "authoring it was not +in this plan's write set" — is contradicted by the same plan task having authored two other +`docs/features/potential/` entries. That is finding F-1 in `policy-audit.2026-09-08T11-30.md` and it +is the one item that must be closed before merge. It costs one file. + +## 5. Findings Summary + +| ID | Severity | Summary | Location | +|---|---|---|---| +| C-1 | Minor | One test performs two Acts to respect a 500-line ceiling | `OlTableExtensions_Tests.cs`, `EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart` | +| C-2 | Minor | Test name is stronger than its surviving assertion | `StackGeek_Tests.Main_RunsSampleScenarioWithoutThrowing` | +| C-3 | Minor | Two null-writer tests write to real `Console.Out` under an unenforced assembly-wide assumption | `PrettyPrint_Tests`, `OlTableExtensions_Tests` | +| C-4 | Observation | New gate-driving classes correctly `[DoNotParallelize]`; serialized set shrinks by one | `DfDeedleEtlTimeoutTests.cs`, `OlTableExtensionsEtlClockTests.cs` | +| C-5 | Observation | `MessageBoxInvoker` safety claim is true but unenforced and rests on an unverified ordering assumption | `DfDeedle_COM_Tests.cs` header, `DfDeedle.cs:60` | +| C-6 | Observation | Named and positional tuple access mixed within four lines | `DfDeedle.cs`, null-snapshot guard and `LogDfTiming` | + +No finding in this artifact is Blocking. None requires a code change before merge. + +## 6. Recommendations + +1. Close F-1 from the policy audit: file the `ILGlobals` race as a + `docs/features/potential/` entry and promote it to an issue. Candidate fixes, in order of + preference: initialise both tables in a static constructor or via `Lazy` so they are + published once and immutably; or take a `lock` inside `LoadOpCodes()` and around the reads; or, + as an interim stopgap only, add `[DoNotParallelize]` to `ILGlobals_Tests` and + `MethodBodyReader_Tests`. The first is preferred because `LoadOpCodes()` is idempotent and the + tables are conceptually constant. +2. Split the two Acts in C-1 into a separate small test file, which also reduces a file that is + 1346 lines over the cap. +3. Add the enforcement caveat described in C-5 to the `DfDeedle_COM_Tests` class header. +4. Consider following the `spec.md` rollout list item to soak `OlTableExtensions_Tests` without + `[DoNotParallelize]` as separate work, now that its console reason is genuinely gone. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t10-vstest-coverage.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t10-vstest-coverage.md new file mode 100644 index 000000000..58db4aa89 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t10-vstest-coverage.md @@ -0,0 +1,69 @@ +# P0-T10 — Baseline toolchain step 4 (nine-assembly coverage run) + +Timestamp: 2026-09-08T09-25 +Task: [P0-T10] +Command: dotnet-coverage collect --output coverage/p0-baseline.cobertura.xml --output-format cobertura --settings coverage.config -- /Settings:scripts\vscode\TaskMaster.cli.runsettings /InIsolation "/Logger:trx;LogFileName=p0-t10.trx" /ResultsDirectory:coverage/trx/p0-t10 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser" +EXIT_CODE: 0 + +This is the mandatory numeric coverage baseline. It uses the D15 argument shape: the repository +wrapper's own shape, issued directly rather than through `Invoke-MSTestWithCoverage.ps1`, because +that wrapper hard-codes its filter and cannot take the shell-icon exclusion, and because its +assembly discovery rejects every assembly under a dot-claude path when run from an agent worktree. + +## TRX counters + +`coverage/trx/p0-t10/p0-t10.trx` exists. + +``` +total=7153 executed=7153 passed=7153 failed=0 error=0 timeout=0 aborted=0 notExecuted=0 +``` + +PREEXISTING_FAILURE_SET: none + +`failed` + `error` + `aborted` + `timeout` = 0. No test failed on this run, including the two +sentinels this item repairs: +`DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` and +`DfDeedle_COM_Tests.GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform`. Both +passed. That is the expected shape of a load-dependent defect: the failures are intermittent, not +deterministic, which is exactly why AC1 has no deterministic RED (D6) and why AC4 requires ten +consecutive runs rather than one. A green baseline is not evidence that the defect is absent. + +## Root Cobertura figures + +Read from the root element of `coverage/p0-baseline.cobertura.xml`. `line-rate` and `branch-rate` +are fractions, not percentages. + +| Attribute | Value | +|---|---| +| `line-rate` | `0.8601092896174863` | +| `lines-covered` | `172353` | +| `lines-valid` | `200385` | +| `branch-rate` | `0.6630576006929406` | +| `branches-covered` | `21434` | +| `branches-valid` | `32326` | + +Line coverage is 86.01 percent repository-wide on the merged denominator. + +## Wall clock + +RUN_SECONDS: 64 + +vstest reported `Total time: 1.0297 Minutes`. The #798 baseline measured 54.5 s for the same +nine-assembly shape, so this run is consistent with it. + +## Acceptance evaluation + +- `EXIT_CODE: 0` recorded; the run had no failing test, so the zero-failure branch of the + acceptance applies and no `PREEXISTING_FAILURE_SET:` entries exist. PASS +- `coverage/trx/p0-t10/p0-t10.trx` exists and its counters are recorded in the + `total= executed= passed= failed= error= timeout= aborted= notExecuted=` form. PASS +- `failed` + `error` + `aborted` + `timeout` equals 0. PASS +- `coverage/p0-baseline.cobertura.xml` exists and all six root figures are recorded as numbers. + PASS + +## Output Summary + +Nine-assembly `/InIsolation` coverage baseline: 7153 tests, 7153 passed, 0 failed, 64 s wall +clock, exit 0. Repository-wide line coverage 0.8601 (172353 of 200385 lines); branch coverage +0.6631 (21434 of 32326 branches). Raw Cobertura and TRX documents stay under the gitignored +`coverage/` directory and are not committed (D15). diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t11-per-file-coverage.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t11-per-file-coverage.md new file mode 100644 index 000000000..20cf1a7af --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t11-per-file-coverage.md @@ -0,0 +1,89 @@ +# P0-T11 — Baseline per-file coverage for the eight production files + +Timestamp: 2026-09-08T09-26 +Task: [P0-T11] +Command: pwsh -NoProfile -File coverage/plan811-helper.ps1 running PROC-COV (`Get-FileLineMap`, `Get-FileSummary`, `Get-MethodLineRate`) over `coverage/p0-baseline.cobertura.xml` +EXIT_CODE: 0 + +PROC-COV merges the `./lines/line` and `./methods/method/lines/line` node axes by line number, +keeping the maximum `hits`, which is the repository helper's own dedup rule. Suffixes are written +with the single backslashes the collector emits. + +## Per-file summaries + +| Suffix | valid | covered | uncovered | +|---|---|---|---| +| `UtilitiesCS\Extensions\DictionaryExtensions.cs` | 108 | 101 | 7 | +| `UtilitiesCS\OutlookObjects\Table\OlTableExtensions.Etl.cs` | 297 | 271 | 26 | +| `UtilitiesCS\Extensions\DfDeedle.cs` | 159 | 157 | 2 | +| `UtilitiesCS\Extensions\DfDeedle.FrameUtilities.cs` | 169 | 117 | 52 | +| `UtilitiesCS\OutlookObjects\Filter DASL\DASLFilterParser.cs` | 79 | 76 | 3 | +| `UtilitiesCS\HelperClasses\PrettyPrint.cs` | 440 | 375 | 65 | +| `UtilitiesCS\OutlookObjects\Table\OlTableExtensions.TableAccess.cs` | 270 | 215 | 55 | +| `UtilitiesCS\ReusableTypeClasses\Other\StackGeek.cs` | 93 | 93 | 0 | + +Every one of the eight matched at least one `class` element, so `valid` is greater than 0 in all +eight cases and no suffix silently measured nothing. + +## Baseline uncovered ceilings for P7-T6 + +These are the figures P7-T6 clause 1 compares against; final `uncovered` must be at most the value +below for each file. + +``` +BASELINE_UNCOVERED: UtilitiesCS\Extensions\DictionaryExtensions.cs=7 +BASELINE_UNCOVERED: UtilitiesCS\OutlookObjects\Table\OlTableExtensions.Etl.cs=26 +BASELINE_UNCOVERED: UtilitiesCS\Extensions\DfDeedle.cs=2 +BASELINE_UNCOVERED: UtilitiesCS\Extensions\DfDeedle.FrameUtilities.cs=52 +BASELINE_UNCOVERED: UtilitiesCS\OutlookObjects\Filter DASL\DASLFilterParser.cs=3 +BASELINE_UNCOVERED: UtilitiesCS\HelperClasses\PrettyPrint.cs=65 +BASELINE_UNCOVERED: UtilitiesCS\OutlookObjects\Table\OlTableExtensions.TableAccess.cs=55 +BASELINE_UNCOVERED: UtilitiesCS\ReusableTypeClasses\Other\StackGeek.cs=0 +``` + +## `StackGeek.Main` method line rate + +MAIN_LINE_RATE: 1 + +`Get-MethodLineRate` for suffix `UtilitiesCS\ReusableTypeClasses\Other\StackGeek.cs`, method +`Main`, returned the number `1`, not `ABSENT`. `Main` is fully covered at baseline, which is the +comparison point for the new `Run(TextWriter)` extraction that P5-T4 creates and P7-T6 clause 3 +holds to a line rate of at least 0.90. + +## `EtlAsync` span + +`EtlAsync` is `async` and emits no `` element in this pipeline's Cobertura output, so the +span rule applies instead: the declaration line through the closing brace. + +``` +ETLASYNC_SPAN: 66-130 +ETLASYNC_SPAN_RATE: 35/50 +``` + +50 of the 65 lines in the span carry a line node; 35 of those are covered, giving a span rate of +0.700. The 15 uncovered lines in the span are: + +``` +94, 109, 110, 111, 112, 113, 114, 115, 117, 118, 119, 120, 121, 122, 123 +``` + +Lines 117 through 123 are the `catch (TimeoutException)` block — the deadline-expiry path that is +the mechanism of the AC2 defect and is entirely unexercised at baseline. Line 114 is the +`.TimeoutAfter(milliseconds, attempts)` call on the `GetArray` branch. P7-T6 clause 4 requires the +final span rate to be at least this 35/50, and the new +`OlTableExtensionsEtlClockTests.EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource` +test is what covers the `catch` block for the first time. + +## Acceptance evaluation + +- All eight suffixes matched at least one `class` element with `valid` greater than 0. PASS +- `Get-MethodLineRate` for `Main` returned `1`, a number rather than `ABSENT`. PASS +- The `EtlAsync` span `valid` is 50, greater than 0. PASS +- `BASELINE_UNCOVERED: =` is recorded for each of the eight. PASS + +## Output Summary + +Eight production files measured against the P0-T10 baseline Cobertura document. Uncovered line +counts range from 0 (`StackGeek.cs`) to 65 (`PrettyPrint.cs`). `StackGeek.Main` is at line rate 1. +The `EtlAsync` span 66-130 is at 35 of 50 covered lines, with the entire `catch (TimeoutException)` +block (117-123) uncovered at baseline. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t12-ci-shape-probe.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t12-ci-shape-probe.md new file mode 100644 index 000000000..8d2b8d488 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t12-ci-shape-probe.md @@ -0,0 +1,64 @@ +# P0-T12 — CI-verbatim AC4 command-shape probe + +Timestamp: 2026-09-08T09-28 +Task: [P0-T12] +Command: /EnableCodeCoverage /InIsolation "/Logger:trx;LogFileName=p0-t12.trx" /ResultsDirectory:coverage/trx/p0-t12 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser" +EXIT_CODE: 0 + +AC4_COMMAND_SHAPE: CI-VERBATIM + +This probe was run once against the pre-change tree, before any source edit, to establish whether +the CI-verbatim shape is locally viable. No `/Settings:` file is passed, so parallelism comes only +from `[assembly: Parallelize(Workers = 0, Scope = ClassLevel)]` at +`UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21`, exactly as in +`.github/workflows/_mstest-coverage.yml:99`. The nine assemblies are enumerated explicitly rather +than discovered recursively, so the dot-claude worktree discovery problem cannot arise. + +## TRX counters + +`coverage/trx/p0-t12/p0-t12.trx` exists. + +``` +total=7153 executed=7153 passed=7153 failed=0 error=0 timeout=0 aborted=0 notExecuted=0 +``` + +No test produced an outcome other than `Passed`. The failing-test enumeration the acceptance +condition would otherwise require is therefore empty, and the CI-VERBATIM branch applies: the set +of failing tests is a subset of the six named failure modes this item repairs because it is the +empty set. + +## Wall clock + +PROBE_SECONDS: 62 + +A P8 pair task runs two such runs back to back, so the estimate for each of P8-T1 through P8-T5 is +about 124 s of test time plus process startup, comfortably inside the 600000 ms tool timeout. + +## `.coverage` file cleanup + +| Observation | Value | +|---|---| +| `*.coverage` files found under `coverage/trx/p0-t12` | 2 | +| `*.coverage` files remaining after deletion | 0 | + +vstest emits two `.coverage` files per run under `/EnableCodeCoverage`. Both were deleted after +the counters were read, as the task requires. They are large binaries and are never committed. + +## Consequence for Phase 8 + +Because `AC4_COMMAND_SHAPE` is `CI-VERBATIM`, the P8-T1 through P8-T5 runs use the base argument +shape with no `/Settings:` argument. The `RUNSETTINGS-FALLBACK` branch does not apply and +`/Settings:TaskMaster.runsettings` is not added. + +## Acceptance evaluation + +- `coverage/trx/p0-t12/p0-t12.trx` exists. PASS +- Counters recorded. PASS +- `PROBE_SECONDS: 62` recorded. PASS +- Exactly one of the two shape verdicts is recorded: `AC4_COMMAND_SHAPE: CI-VERBATIM`. PASS +- `.coverage` files under the results directory were deleted after the counters were read. PASS + +## Output Summary + +The CI-verbatim AC4 command shape is locally viable. One probe run: 7153 tests, 7153 passed, 0 +failed, 62 s, exit 0. Phase 8 will use the CI-verbatim shape with no runsettings file. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t2-worktree-identity.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t2-worktree-identity.md new file mode 100644 index 000000000..8a81e06b3 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t2-worktree-identity.md @@ -0,0 +1,51 @@ +# P0-T2 — Worktree identity and anchor commit + +Timestamp: 2026-09-08T09-17 +Task: [P0-T2] +Command: pwsh -NoProfile -File coverage/plan811-helper.ps1 (C1 preamble, then the six git observations below) +EXIT_CODE: 0 + +BASE-SHA: bb1c7d4b60f7b782227956f36859314d5c47bb03 + +## Observations + +| Observation | Value | +|---|---| +| `git rev-parse HEAD` | `bb1c7d4b60f7b782227956f36859314d5c47bb03` | +| `git rev-parse --abbrev-ref HEAD` | `bug/utilitiescs-test-determinism-780-803-594-811` | +| `git cat-file -t bb1c7d4b60f7b782227956f36859314d5c47bb03` | `commit` | +| `git merge-base HEAD bb1c7d4b60f7b782227956f36859314d5c47bb03` | `bb1c7d4b60f7b782227956f36859314d5c47bb03` | +| `git rev-parse origin/main` | `0e9c95a5dd45104d82f46fd801973a6bc068f25f` | +| `git merge-base --is-ancestor origin/main HEAD` exit code | `0` | + +## Scoped porcelain + +`git status --porcelain --untracked-files=all -- . ":(exclude).claude"` + +``` + M docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/plan.2026-09-07T22-03.md +?? docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/phase0-instructions-read.md +``` + +Both entries are under the feature folder. No path outside `/` is listed. The modified +plan file carries the orchestrator's re-anchor edit (decision D16) plus this executor's P0-T1 +check-off. The untracked file is the P0-T1 evidence artifact. The C1 helper script +`coverage/plan811-helper.ps1` does not appear because `coverage/*` is gitignored (`.gitignore:144`). + +## Acceptance evaluation + +- Branch name equals `bug/utilitiescs-test-determinism-780-803-594-811`. PASS +- `cat-file -t` prints `commit`. PASS +- Merge-base output equals the anchor, so the anchor is an ancestor of HEAD; here it is HEAD + itself, as expected until P7-T12 commits. PASS +- Scoped porcelain lists only paths under `/`. PASS +- `BASE-SHA:` present on its own line; no host path anywhere in this artifact. PASS +- `git merge-base --is-ancestor origin/main HEAD` exited 0, so the merged `origin/main` + (`0e9c95a5dd45104d82f46fd801973a6bc068f25f`) is still contained in this branch. Main has not + advanced since the re-anchor. No reconciliation is required before source edits. PASS + +## Output Summary + +Worktree identity confirmed. HEAD equals the D16 anchor `bb1c7d4b`. `origin/main` at `0e9c95a5` +is an ancestor of HEAD (exit 0), so the re-anchored base is still valid. Working tree carries only +two feature-folder paths and nothing outside them. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t3-toolchain-bootstrap.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t3-toolchain-bootstrap.md new file mode 100644 index 000000000..3f31f0e35 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t3-toolchain-bootstrap.md @@ -0,0 +1,51 @@ +# P0-T3 — Repo-local SDK bootstrap and tool-manifest restore + +Timestamp: 2026-09-08T09-18 +Task: [P0-T3] +Command: pwsh -NoProfile -File coverage/plan811-helper.ps1 wrapping `pwsh -NoProfile -File scripts/vscode/Install-RepoDotNetSdk.ps1`, then `.dotnet-sdk\dotnet.exe --version`, then `.dotnet-sdk\dotnet.exe tool restore` +EXIT_CODE: 0 + +BRANCH APPLIED: INSTALL + +`Test-Path .dotnet-sdk\dotnet.exe` returned `False` before the task, so the SDK acquisition branch +applied. This is a fresh agent worktree that had never been bootstrapped for C# builds. + +## Observations + +| Observation | Value | +|---|---| +| `Test-Path .dotnet-sdk\dotnet.exe` before | `False` | +| `Install-RepoDotNetSdk.ps1` exit code | `0` | +| `Test-Path .dotnet-sdk\dotnet.exe` after | `True` | +| `dotnet --version` | `8.0.205` | +| `dotnet --version` exit code | `0` | +| `dotnet tool restore` exit code | `0` | + +Installer output (host path redacted to ``): + +``` +Downloading .NET SDK 8.0.205 from https://builds.dotnet.microsoft.com/dotnet/Sdk/8.0.205/dotnet-sdk-8.0.205-win-x64.zip... +Installed repo-local .NET SDK 8.0.205 to \.dotnet-sdk. +``` + +Tool-restore output, verbatim: + +``` +Tool 'csharpier' (version '1.2.6') was restored. Available commands: csharpier + +Restore was successful. +``` + +## Acceptance evaluation + +- `dotnet --version` prints `8.0.205`, matching the `global.json` pin. PASS +- Tool-restore output contains the line + `Tool 'csharpier' (version '1.2.6') was restored. Available commands: csharpier`. PASS +- Both commands exited 0. PASS +- The artifact records which branch applied (INSTALL). PASS + +## Output Summary + +Repo-local .NET SDK 8.0.205 downloaded and installed under `.dotnet-sdk` on the INSTALL branch. +Manifest-pinned CSharpier 1.2.6 restored. Both steps exited 0, so the format gate can now be +invoked through `dotnet tool run`. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t4-package-restore.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t4-package-restore.md new file mode 100644 index 000000000..2d6050616 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t4-package-restore.md @@ -0,0 +1,36 @@ +# P0-T4 — NuGet packages.config restore + +Timestamp: 2026-09-08T09-18 +Task: [P0-T4] +Command: pwsh -NoProfile -File scripts/vscode/Invoke-Restore.ps1 +EXIT_CODE: 0 + +The wrapper runs msbuild `/t:Restore /p:RestorePackagesConfig=true` against `TaskMaster.sln`. Its +`-SolutionPath`, `-Configuration` and `-Platform` parameters were left at their defaults +(`TaskMaster.sln`, `Debug`, `Any CPU`). No package was added, removed or version-changed by this +task; `packages.config` files are untouched. + +## Observations + +| Observation | Value | +|---|---| +| `Invoke-Restore.ps1` exit code | `0` | +| msbuild summary | `Build succeeded.` / `0 Warning(s)` / `0 Error(s)` | +| `Test-Path packages\Microsoft.Bcl.TimeProvider.10.0.11\lib` | `True` | +| `Test-Path packages\Microsoft.Extensions.TimeProvider.Testing.10.9.0\lib` | `True` | +| Directories directly under `packages\` | `172` | + +## Acceptance evaluation + +- Exit code 0. PASS +- Both seam-package `lib` directories print `True`. These are the two packages spec.md relies on + (`Microsoft.Bcl.TimeProvider 10.0.11` for the production `TimeProvider` seam, + `Microsoft.Extensions.TimeProvider.Testing 10.9.0` for `FakeTimeProvider` in tests). Both were + already referenced before this change; the plan adds no package. PASS +- The count of directories directly under `packages\` is recorded (172). PASS + +## Output Summary + +Solution-wide packages.config restore succeeded with 0 warnings and 0 errors. 172 package +directories present. Both `TimeProvider` seam packages resolved, so the seam work in Phase 1 and +the `FakeTimeProvider` tests in Phases 2, 5 and 6 can compile without any package change. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t5-tool-resolution.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t5-tool-resolution.md new file mode 100644 index 000000000..f499acfb4 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t5-tool-resolution.md @@ -0,0 +1,38 @@ +# P0-T5 — External tool resolution + +Timestamp: 2026-09-08T09-19 +Task: [P0-T5] +Command: pwsh -NoProfile -File coverage/plan811-helper.ps1 (C1 preamble resolves `$vstest` and `$msbuild` through vswhere; then `Get-Command dotnet-coverage` and `dotnet-coverage --version`) +EXIT_CODE: 0 + +All resolved paths below are redacted: the Visual Studio installation root is written as +`` and the user profile root as ``. + +## Observations + +| Tool | Resolved (redacted) | Non-empty | `Test-Path` | +|---|---|---|---| +| `vstest.console.exe` | `\Common7\IDE\Extensions\TestPlatform\vstest.console.exe` | `True` | `True` | +| `MSBuild.exe` | `\MSBuild\Current\Bin\MSBuild.exe` | `True` | `True` | +| `dotnet-coverage` | `\.dotnet\tools\dotnet-coverage.exe` | `True` | resolved by `Get-Command` | + +| Observation | Value | +|---|---| +| `dotnet-coverage` branch | ALREADY-PRESENT (no install was required) | +| `dotnet-coverage --version` | `18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342` | +| `dotnet-coverage --version` exit code | `0` | + +## Acceptance evaluation + +- `$vstest` and `$msbuild` are both non-empty and `Test-Path` on each prints `True`. PASS +- `dotnet-coverage --version` exited 0 and printed a version string beginning with a digit + (`18.10.0...`). PASS +- `Get-Command dotnet-coverage` succeeded, so the global-install branch did not apply; the + ALREADY-PRESENT branch is recorded. PASS +- Every resolved path is redacted to `` or `` in this artifact. PASS + +## Output Summary + +All three external tools resolved on the first attempt. vstest.console.exe and MSBuild.exe located +through vswhere against the latest installed Visual Studio; dotnet-coverage 18.10.0 already present +as a global .NET tool, so no install branch was taken. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t6-citation-baseline.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t6-citation-baseline.md new file mode 100644 index 000000000..d0213cd62 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t6-citation-baseline.md @@ -0,0 +1,99 @@ +# P0-T6 — Citation baseline against the merged tree + +Timestamp: 2026-09-08T09-20 +Task: [P0-T6] +Command: pwsh -NoProfile -File coverage/plan811-helper.ps1 — `(Get-Content -LiteralPath ).Count` for line counts, `@(Select-String -LiteralPath -CaseSensitive -SimpleMatch '').Count` for token counts, `Test-Path` for the three new files +EXIT_CODE: 0 + +MISMATCH_COUNT: 0 + +No `CITATION-MISMATCH:` line was produced. Execution proceeds; the plan's one permitted halt did +not fire. All measurements were taken against the merged base tree +`bb1c7d4b60f7b782227956f36859314d5c47bb03` (decision D16), before any source edit. + +## Line counts (17 of 17 match) + +| Path | Expected | Observed | +|---|---|---| +| `UtilitiesCS/Extensions/DictionaryExtensions.cs` | 282 | 282 | +| `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` | 474 | 474 | +| `UtilitiesCS/Extensions/DfDeedle.cs` | 314 | 314 | +| `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs` | 276 | 276 | +| `UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs` | 122 | 122 | +| `UtilitiesCS/HelperClasses/PrettyPrint.cs` | 680 | 680 | +| `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs` | 430 | 430 | +| `UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs` | 199 | 199 | +| `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs` | 296 | 296 | +| `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` | 869 | 869 | +| `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` | 500 | 500 | +| `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs` | 276 | 276 | +| `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs` | 408 | 408 | +| `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs` | 125 | 125 | +| `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` | 1855 | 1855 | +| `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs` | 119 | 119 | +| `UtilitiesCS/Threading/TimeOutTask.cs` | 1011 | 1011 | + +## New files absent (3 of 3 match) + +| Path | Expected | Observed | +|---|---|---| +| `UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs` | ABSENT | ABSENT | +| `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` | ABSENT | ABSENT | +| `UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs` | ABSENT | ABSENT | + +## Token counts (42 of 42 match) + +All counts are case-sensitive `-SimpleMatch` line counts, per C2. + +| File | Token | Expected | Observed | +|---|---|---|---| +| `DictionaryExtensions.cs` | `CancelAfter(` | 1 | 1 | +| `DictionaryExtensions.cs` | `CreateLinkedTokenSource` | 1 | 1 | +| `OlTableExtensions.Etl.cs` | `var attempts = 3;` | 2 | 2 | +| `OlTableExtensions.Etl.cs` | `TimeoutAfter(milliseconds, attempts)` | 2 | 2 | +| `OlTableExtensions.Etl.cs` | `TimeoutAfter(timeout, attempts)` | 2 | 2 | +| `OlTableExtensions.Etl.cs` | `timed out {attempts} times` | 2 | 2 | +| `OlTableExtensions.Etl.cs` | `DateTime.Now` | 2 | 2 | +| `OlTableExtensions.Etl.cs` | `TimeProvider` | 0 | 0 | +| `DfDeedle.cs` | `TableEtlInvoker` | 3 | 3 | +| `DfDeedle.cs` | `TimeoutAfter(1000, 2)` | 2 | 2 | +| `DfDeedle.cs` | `TimeProvider` | 0 | 0 | +| `DfDeedle.cs` | `was not produced` | 0 | 0 | +| `DfDeedle.FrameUtilities.cs` | `StoreTableEtlInvoker` | 1 | 1 | +| `DASLFilterParser.cs` | `Console.WriteLine` | 1 | 1 | +| `DASLFilterParser.cs` | `TextWriter` | 0 | 0 | +| `PrettyPrint.cs` | `Console.WriteLine` | 2 | 2 | +| `PrettyPrint.cs` | `TextWriter` | 0 | 0 | +| `OlTableExtensions.TableAccess.cs` | `Console.WriteLine` | 5 | 5 | +| `OlTableExtensions.TableAccess.cs` | `TextWriter` | 0 | 0 | +| `StackGeek.cs` | `Console.WriteLine` | 7 | 7 | +| `StackGeek.cs` | `public static void Run(` | 0 | 0 | +| `DfDeedle_COM_Tests.cs` | `TableEtlInvoker` | 14 | 14 | +| `DfDeedle_COM_Tests.cs` | `FakeTimeProvider` | 0 | 0 | +| `DfDeedleQfcColumnTimeoutTests.cs` | `class ArmingBarrierTimeProvider` | 1 | 1 | +| `StackGeek_Tests.cs` | `Console.SetOut` | 3 | 3 | +| `StackGeek_Tests.cs` | `DoNotParallelize` | 1 | 1 | +| `PrettyPrint_Tests.cs` | `Console.SetOut` | 3 | 3 | +| `PrettyPrint_Tests.cs` | `DoNotParallelize` | 2 | 2 | +| `DASLFilterParserTests.cs` | `Console.SetOut` | 3 | 3 | +| `DASLFilterParserTests.cs` | `DoNotParallelize` | 1 | 1 | +| `OlTableExtensions_Tests.cs` | `Console.SetOut` | 2 | 2 | +| `OlTableExtensions_Tests.cs` | `DoNotParallelize` | 1 | 1 | +| `OlTableExtensions_Tests.cs` | `Returns(120)` | 1 | 1 | +| `OlTableExtensions_Tests.cs` | `cannot fire under test-host` | 1 | 1 | +| `OlTableExtensions_Tests.cs` | `redirects Console.Out` | 1 | 1 | +| `OlTableExtensions_Tests.cs` | `FakeTimeProvider` | 0 | 0 | +| `NLogTraceWriter_Test.cs` | `Console.SetOut` | 2 | 2 | +| `NLogTraceWriter_Test.cs` | `originalOut` | 3 | 3 | +| `NLogTraceWriter_Test.cs` | `TestCleanup` | 2 | 2 | +| `UtilitiesCS.Test.csproj` | `Extensions\DfDeedleEtlTimeoutTests.cs` | 0 | 0 | +| `UtilitiesCS.Test.csproj` | `OutlookObjects\Table\OlTableExtensionsEtlClockTests.cs` | 0 | 0 | +| `UtilitiesCS.Test.csproj` | `TestHelpers\ArmingBarrierTimeProvider.cs` | 0 | 0 | + +## Output Summary + +62 assertions evaluated (17 line counts, 3 absence checks, 42 token counts). All 62 matched the +plan's stated values exactly. `MISMATCH_COUNT: 0`. The re-anchor recorded in D16 did not move any +value this task asserts: the merge changed only `UtilitiesCS.Test/UtilitiesCS.Test.csproj`, whose +line count is not asserted here, and the three project-file token counts it does assert are all +zero-hit checks for items the plan has yet to add. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t7-csharpier-check.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t7-csharpier-check.md new file mode 100644 index 000000000..4ce9bde5c --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t7-csharpier-check.md @@ -0,0 +1,30 @@ +# P0-T7 — Baseline toolchain step 1 (CSharpier check) + +Timestamp: 2026-09-08T09-21 +Task: [P0-T7] +Command: dotnet tool run csharpier check . +EXIT_CODE: 0 + +Invoked as `& $dotnet tool run csharpier check .` from the worktree root, where `$dotnet` is the +repo-local `.dotnet-sdk\dotnet.exe` installed by P0-T3. Going through `dotnet tool run` binds the +manifest-pinned CSharpier 1.2.6, matching `.github/workflows/_format-check.yml`. + +PRE-EXISTING DRIFT: none + +Exit code 0 and no path was reported as unformatted, so the tree carries no pre-existing formatting +drift. Every rewrite the P7-T1 format pass produces will therefore be attributable to this change. + +## Output Summary + +Summary line, verbatim: + +``` +Checked 1613 files in 7197ms. +``` + +The line begins with `Checked ` and ends with `ms.`, as the acceptance condition requires. The +processed-file count is 1613 on this tree, 26 higher than the 1587 recorded by #798 one day +earlier; the difference is files added by the 31 origin/main commits merged as `bb1c7d4b`, not a +change made by this item. The count is a processed-file count, not a rewrite count, so it is +recorded for provenance and is not the pass signal; exit 0 with no reported path is the pass +signal. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t8-msbuild-analyzers.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t8-msbuild-analyzers.md new file mode 100644 index 000000000..09ab3e221 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t8-msbuild-analyzers.md @@ -0,0 +1,50 @@ +# P0-T8 — Baseline toolchain step 2 (msbuild analyzer gate) + +Timestamp: 2026-09-08T09-22 +Task: [P0-T8] +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p0-t8.log;Verbosity=normal" +EXIT_CODE: 0 + +BASELINE_ANALYZER_WARNINGS: 0 + +`/t:Rebuild` was used, not `/t:Build`. A warm `/t:Build` returns exit 0 with `CoreCompile` skipped +on every project, because MSBuild's up-to-date check does not invalidate on a command-line `/p:` +change, so the analyzer gate would run no analyzers and could not fail. + +The normal-verbosity file log is written to `coverage/msbuild-p0-t8.log`, which is gitignored and +is not committed. `/v:q` on the console plus `Verbosity=normal` on the file logger is what makes +the `0 Error(s)` summary line readable; at minimal verbosity MSBuild omits the error summary +entirely. + +## Observations + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines in the file log exactly equal to ` 0 Error(s)` | `1` | +| Distinct analyzer or compiler warning ids in the log | none | +| `Test-Path UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll` after the build | `True` | + +Summary lines quoted verbatim from the file log: + +``` + 0 Warning(s) + 0 Error(s) +``` + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- The file log contains the line ` 0 Error(s)` (found once). The assertion is on that verbatim + summary line, not on the absence of the substring `error`, which a successful msbuild run prints + in switch names and summary text regardless. PASS +- Both the `Warning(s)` and `Error(s)` lines are quoted verbatim, and + `BASELINE_ANALYZER_WARNINGS: 0` is recorded. PASS +- `UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll` exists afterwards. PASS + +## Output Summary + +Full-solution rebuild with .NET analyzers and code-style enforcement enabled: exit 0, 0 warnings, +0 errors. The baseline analyzer warning ceiling for P1-T8, P5-T10 and P7-T3 is therefore 0, which +is a strict ceiling: any analyzer warning introduced by this change will exceed it and fail those +gates. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t9-msbuild-nullable.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t9-msbuild-nullable.md new file mode 100644 index 000000000..5ec61b86e --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/p0-t9-msbuild-nullable.md @@ -0,0 +1,44 @@ +# P0-T9 — Baseline toolchain step 3 (nullable / warnings-as-errors) + +Timestamp: 2026-09-08T09-23 +Task: [P0-T9] +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p0-t9.log;Verbosity=normal" +EXIT_CODE: 0 + +## `/p:Nullable=enable` was not supplied, and why + +`/p:Nullable=enable` was deliberately omitted because nullable enforcement in this repository is +per-file opt-in through the `#nullable enable` directive, and no project carries a `` +element or a `Directory.Build.props`; supplying the property solution-wide would conscript every +file that has never adopted the pragma, which is why `.github/workflows/_build-nullable.yml` +omits it as well (CLAUDE.md C#1.3). Omitting it loses no enforcement over any file that has opted +in, and all eight production files in this change carry `#nullable enable`. + +`/t:Rebuild` was used for the same reason as P0-T8: a warm `/t:Build` skips `CoreCompile` and the +gate cannot fail. + +## Observations + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines in the file log exactly equal to ` 0 Error(s)` | `1` | + +Summary lines quoted verbatim from `coverage/msbuild-p0-t9.log`: + +``` + 0 Warning(s) + 0 Error(s) +``` + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- The file log contains ` 0 Error(s)`. PASS +- The artifact states in one sentence that `/p:Nullable=enable` was not supplied and why. PASS + +## Output Summary + +Full-solution rebuild with `/p:TreatWarningsAsErrors=true`: exit 0, 0 warnings, 0 errors. The +nullable baseline is clean, so any `CS86xx` diagnostic introduced by the Phase 1 or Phase 3 edits +to the eight `#nullable enable` production files will surface as a build error at P7-T4. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/phase0-instructions-read.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/phase0-instructions-read.md new file mode 100644 index 000000000..ac75ad190 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/baseline/phase0-instructions-read.md @@ -0,0 +1,47 @@ +# P0-T1 — Phase 0 policy read record + +Timestamp: 2026-09-08T09-16 +Task: [P0-T1] +Command: Read tool over each listed path in the item worktree; `pwsh -NoProfile -File coverage/plan811-helper.ps1` for the Test-Path and line reads +EXIT_CODE: 0 + +Policy Order: + +1. `CLAUDE.md` +2. `.claude/rules/general-code-change.md` +3. `.claude/rules/general-unit-test.md` +4. `.claude/rules/csharp.md` +5. `.claude/rules/tonality.md` +6. `.claude/rules/plan-acceptance-gates.md` +7. `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/issue.md` +8. `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/spec.md` +9. `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/research/root-cause.2026-09-07T22-10.md` + +All nine paths were read in that order and in full. + +Work Mode: full-bug + +Read from `issue.md` line 12, whose verbatim text is `- Work Mode: full-bug`. + +AC Source: spec.md lines 292-296 (AC1..AC5) + +`spec.md` line 291 is the heading `## Acceptance Criteria`; lines 292 through 296 carry +`- [ ] AC1:` through `- [ ] AC5:`, all five unchecked at Phase 0. + +user-story.md: ABSENT (expected) + +`Test-Path` on `/user-story.md` returned `False`. `full-bug` work mode takes `spec.md` +as its sole acceptance-criteria source, so the absence is the expected state and not a gap. + +## Output Summary + +Nine policy and requirement documents read in the mandated order. Work mode marker confirmed as +`full-bug` at `issue.md` line 12. Five acceptance criteria confirmed present and unchecked at +`spec.md` lines 292-296. `user-story.md` confirmed absent. Four of the six policy files +(`CLAUDE.md`, `general-code-change.md`, `general-unit-test.md`, `tonality.md`) were additionally +verified byte-identical to the copies already loaded in the executor session by comparing +`git hash-object` blob hashes across the two worktrees; all four hashes matched. + +Note on `issue.md`: it carries an AC-shaped list under `## Proposed Fix / Validation Ideas`. +Per the `acceptance-criteria-tracking` skill that list is NOT the authority for `full-bug`; +`spec.md` is. Only `spec.md` is checked off by this plan. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/issue-updates/issue-811.2026-09-08T10-44.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/issue-updates/issue-811.2026-09-08T10-44.md new file mode 100644 index 000000000..e2bf6cec0 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/issue-updates/issue-811.2026-09-08T10-44.md @@ -0,0 +1,92 @@ +# Issue update mirror — #811 + +Timestamp: 2026-09-08T10-44 +POSTING BLOCKED: GitHub posting is gated to the orchestrator by repository hooks +PostedAs: unknown + +## Deviation from the plan's predicted values + +P8-T10's acceptance predicted `Checked off (delivered): 5` and `Remaining (unchecked): 0`. The +observed state is 4 delivered and 1 remaining, because AC4's ten-run gate recorded one failing run +(see `evidence/regression-testing/p8-t6-ac4-ten-run.md`). The true figures are written below. This +task is therefore left unchecked in the plan: its acceptance condition as written cannot be +satisfied without misreporting the result, and the fail-closed evidence rule makes misreporting the +worse outcome. + +--- + +## Text intended for the issue + +### Acceptance Criteria Status + +- Source: `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/spec.md` +- Total AC items: 5 +- Checked off (delivered): 4 +- Remaining (unchecked): 1 +- Items remaining: AC4: A full nine-assembly `/InIsolation` run with `TestCategory!=LiveOutlook` reports zero failures on ten consecutive runs, recorded as evidence. + +| AC | State | Evidence | +|---|---|---| +| AC1 | delivered | `fail-before-exception.2026-09-08T09-50.md`, `p4-t4-ac1-pass-after.md`, `p8-t6-ac4-ten-run.md` | +| AC2 | delivered | `p1-t9-seam-scoped-run.md`, `p2-t4-ac2-fail-before.md`, `p3-t2-ac2-pass-after.md` | +| AC3 | delivered | `p5-t10-ac3-pass-after.md` | +| AC4 | **not delivered** | `p8-t6-ac4-ten-run.md` — 9 of 10 runs clean | +| AC5 | delivered | `p7-t10-ac5-timing-hack-search.md` | + +### The ten-run gate + +Ten consecutive full nine-assembly `/InIsolation` runs on the unchanged source commit `03b7bd57`, +24 class-level workers, 7162 tests per run. + +| Run | total | executed | passed | failed | error | aborted | timeout | seconds | +|---|---|---|---|---|---|---|---|---| +| 1 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 70.9 | +| 2 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 54.2 | +| 3 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 55.0 | +| 4 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 51.9 | +| 5 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 51.3 | +| 6 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 70.7 | +| 7 | 7162 | 7162 | 7161 | 1 | 0 | 0 | 0 | 73.9 | +| 8 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 69.4 | +| 9 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 54.2 | +| 10 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 58.8 | + +The single failure, on run 7, was +`UtilitiesCS.Test.NewtonsoftHelpers.SDILReader.MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions`. +It is a different defect from the three this item repairs: an unsynchronised process-wide static in +`UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs`, whose `LoadOpCodes()` reassigns +`singleByteOpCodes` to a fresh all-default array before filling it, raced by two test classes that +both call it and neither of which carries `[DoNotParallelize]`. Neither file is in this item's +write set and neither was modified here. It needs its own issue. + +All 13 tests this change adds, renames or repairs read `Passed` in every one of the ten runs (130 +assertions, 0 not-passed), including both intermittent-failure sentinels +`TryAddValuesAsync_UpdatesExistingValue` and +`GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform`. On this evidence the three +defects #811 targets did not recur. + +The local filter extends `TestCategory!=LiveOutlook` with the documented shell-icon exclusion, an +environmental workstation issue covered by CI. The CI run on the pull request supplies the +unfiltered single-run form; one CI run is not ten. + +### Follow-ups recorded + +- `docs/features/potential/2026-09-08-etl-deadline-mechanics-follow-ups.md` +- `docs/features/potential/2026-09-08-console-out-aggressors-and-banned-symbol-promotion.md` + +A third follow-up is needed for the `ILGlobals` static race described above; it is reported to the +orchestrator rather than filed here, because authoring it was not in this plan's write set. + +### Closure pointers + +#780, #803 and #594 are superseded by #811. + +--- + +## Acceptance evaluation + +- The file exists at the canonical path. PASS +- Contains `Total AC items: 5`. PASS +- Contains `Checked off (delivered): 5`. **FAIL** — the observed value is 4 and the artifact records + the observed value rather than the predicted one. +- Contains no host token. PASS (re-verified by P8-T12) diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p4-t5-ac1-checkoff-note.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p4-t5-ac1-checkoff-note.md new file mode 100644 index 000000000..e89f45c91 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p4-t5-ac1-checkoff-note.md @@ -0,0 +1,71 @@ +# P4-T5 — AC1 check-off note and its outstanding dependency + +Timestamp: 2026-09-08T09-52 +Task: [P4-T5] +Command: Edit of `/spec.md` line 292, changing `- [ ] AC1:` to `- [x] AC1:` with the criterion text unchanged +EXIT_CODE: 0 + +## Criterion + +AC1: `TryAddValuesAsync` no longer cancels on a fixed wall-clock window, or the window is driven by +an injected `TimeProvider`; the test passes deterministically under 24-worker parallel coverage +runs. + +## Evidence cited for the check-off + +| Clause | Evidence | +|---|---| +| "no longer cancels on a fixed wall-clock window" (first disjunct) | `evidence/regression-testing/fail-before-exception.2026-09-08T09-50.md` — `CancelAfter(` and `CreateLinkedTokenSource` both moved from 1 to 0 in `UtilitiesCS/Extensions/DictionaryExtensions.cs`, with `TryAddValues(key, value), token)` moving from 0 to 1 as the positive control | +| cancellation contract preserved | `evidence/regression-testing/p4-t4-ac1-pass-after.md` — `TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` passes | +| "the test passes" | `evidence/regression-testing/p4-t4-ac1-pass-after.md` — `TryAddValuesAsync_UpdatesExistingValue` passes in 2.76 ms | + +AC1 is satisfied by its first disjunct. The `TimeProvider` alternative was not taken, because the +deleted window had no production consumer and guarded a bounded compare-and-swap loop, so a seam +would have preserved a deadline with no semantic (decision D3). + +## Outstanding dependency at the time of this check-off + +The final clause of AC1, "the test passes deterministically under 24-worker parallel coverage +runs", is **not** discharged by any evidence available at P4-T5. A single scoped run of one test +class proves the test passes; it does not prove it passes under parallel load, which is the exact +condition the #780 failure depends on. + +That clause is evidenced by **P8-T6**, the ten-run AC4 gate: ten consecutive full nine-assembly +`/InIsolation` runs with `Workers = 0` resolving to the local processor count, in which +`TryAddValuesAsync_UpdatesExistingValue` must pass every time. The check-off is recorded here +together with that dependency so the dependency is auditable rather than implicit. + +## Resolution of that dependency (added 2026-09-08T10-40, after P8-T6 ran) + +DEPENDENCY: DISCHARGED. The AC1 check-off stands. + +P8-T6 ran ten consecutive full-suite runs on the unchanged source commit `03b7bd57` at 24 workers. +`TryAddValuesAsync_UpdatesExistingValue` read `Passed` in **all ten**, which is precisely the +clause this note was tracking: "the test passes deterministically under 24-worker parallel coverage +runs". + +Nine of the ten runs were completely clean. Run 7 reported one failure, but it was a different +test in a different assembly area, +`MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions`, caused by an unsynchronised +static in `ILGlobals` that lies outside this item's write set (see `p8-t4-ac4-runs.md`). That +failure defeats **AC4**, whose condition is a property of the whole run rather than of one test, +and AC4 is correspondingly left unchecked. It does not bear on AC1, whose condition names this +one test, and that test did not fail once in ten runs. + +This supersedes the earlier conservative wording in this note, which said the check-off must be +reverted if P8-T6 did not produce ten clean runs. That wording conflated the two criteria: AC1's +clause is about the #780 test specifically, and AC4's is about the run as a whole. The precise +condition for AC1 was met. + +The reconciliation task P8-T11 lists `fail-before-exception`, `p4-t4-ac1-pass-after.md` and +`p8-t6-ac4-ten-run.md` as the three artifacts that jointly discharge AC1. + +## Acceptance evaluation + +- `'- [x] AC1:'` counts 1 and `'- [ ] AC1:'` counts 0 in spec.md. Verified in the same task run. +- This note artifact exists and names P8-T6 as the evidence for the parallel-load clause. PASS + +## Output Summary + +AC1 checked off in spec.md on the strength of the structural deletion proof and the P4-T4 pass-after +run. The parallel-determinism clause remains dependent on P8-T6 and is recorded as such. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p6-t2-reflection-binding-check.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p6-t2-reflection-binding-check.md new file mode 100644 index 000000000..b5113b9e3 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p6-t2-reflection-binding-check.md @@ -0,0 +1,95 @@ +# P6-T2 — Reflection binding re-verification + +Timestamp: 2026-09-08T10-01 +Task: [P6-T2] +Command: Grep over `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` for `EtlByRowAsync` and over `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` for the same, after all Phase 1, 5 and 6 edits +EXIT_CODE: 0 + +REFLECTION_BINDING: UNCHANGED + +A reflection binding is invisible to the compiler: a signature change that breaks it produces no +build error and instead fails at run time with a null `MethodInfo`. This task re-checks it after +every edit rather than relying on the P5-T10 build. + +## The reflection call + +`UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs`, in +`EtlByRowAsync_PrivateHelper_ReturnsConvertedRows` (test method opens at line 1098; the call spans +lines 1122-1135, originally cited as 1124-1137 and now two lines earlier because P6-T1 removed the +four `Returns(120)` tolerance lines above it): + +```csharp +var asyncRows = await InvokeStaticAsync>( + "EtlByRowAsync", + new[] + { + typeof(Outlook.Table), + typeof(Dictionary>), + typeof(Dictionary), + typeof(CancellationToken), + }, + mockTable.Object, + converters, + columnDictionary, + CancellationToken.None +); +``` + +The explicit four-type array selects the four-parameter overload by exact signature match. + +## The four-parameter overload it binds to (unchanged) + +`UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs:173-178`: + +```csharp +private static async Task> EtlByRowAsync( + Table table, + Dictionary>? objectConverters, + Dictionary columnDictionary, + CancellationToken token +) +``` + +Its parameter list is `Table table, Dictionary>? objectConverters, +Dictionary columnDictionary, CancellationToken token` — exactly the four types the +reflection array names, in the same order. P1-T1 did not edit this overload (decision D9, D14). + +## The seven-parameter overload that was edited + +`UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs:231-239`: + +```csharp +private static async Task EtlByRowAsync( + Table table, + Dictionary>? objectConverters, + Dictionary columnDictionary, + CancellationToken token, + int timeout, + TimeProvider? timeProvider, + ProgressTracker? progress = null +) +``` + +Its list ends `TimeProvider? timeProvider, ProgressTracker? progress = null`, which is exactly what +the acceptance condition requires. P1-T1 replaced `int attempts` with `TimeProvider? timeProvider` +in this overload only. + +A third overload also carries the name — the public extension +`EtlByRowAsync(this IAsyncEnumerable, ...)` at line 218 — but it takes five parameters of +different types and cannot be selected by the four-type array. + +## Acceptance evaluation + +- The four-parameter overload's parameter list is unchanged and matches the reflection array + type-for-type and in order. PASS +- The seven-parameter overload's list ends `TimeProvider? timeProvider, ProgressTracker? progress = null`. + PASS +- `REFLECTION_BINDING: UNCHANGED` recorded. PASS + +## Output Summary + +The reflection-bound four-parameter `EtlByRowAsync` overload is untouched by this change, so the +binding in `EtlByRowAsync_PrivateHelper_ReturnsConvertedRows` still resolves. The `TimeProvider` +parameter was added only to the seven-parameter overload, which no test binds by reflection. The +P6-T5 run confirms the test passes at run time, which is the behavioural counterpart to this +static check. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p7-t12-source-commit.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p7-t12-source-commit.md new file mode 100644 index 000000000..a76cf446b --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p7-t12-source-commit.md @@ -0,0 +1,87 @@ +# P7-T12 — Source commit + +Timestamp: 2026-09-08T10-23 +Task: [P7-T12] +Command: `git add -A -- . ":(exclude).claude"`, then `git commit -m "fix(811): make UtilitiesCS.Test deterministic under parallel coverage (AC1-AC3, AC5)"` with a body listing the three defects +EXIT_CODE: 0 + +SOURCE-HEAD: 03b7bd57cacfc902a8b9f4e917ace627ffcac464 + +## Commit contents + +55 files changed, 3092 insertions, 343 deletions. Three files created: + +``` +UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs +UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs +UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs +``` + +The remaining new files are the 32 evidence artifacts written by Phases 0 through 7. The +`:(exclude).claude` pathspec kept the tracked dot-claude agent-memory tree out of the commit. + +## Committed source diff — exactly the 20 Write Set paths + +`git diff --name-only bb1c7d4b60f7b782227956f36859314d5c47bb03..HEAD -- "*.cs" "*.csproj"` + +``` +UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs +UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs +UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs +UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs +UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs +UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs +UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs +UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs +UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs +UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs +UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs +UtilitiesCS.Test/UtilitiesCS.Test.csproj +UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs +UtilitiesCS/Extensions/DfDeedle.cs +UtilitiesCS/Extensions/DictionaryExtensions.cs +UtilitiesCS/HelperClasses/PrettyPrint.cs +UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs +UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs +UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs +UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs +``` + +20 paths, equal to the Write Set. This is now a two-dot range against the committed history rather +than a worktree comparison, so it cannot pass vacuously. + +## Tree state after the commit + +| Observation | Value | +|---|---| +| `git status --porcelain -- "*.cs" "*.csproj"` | empty | +| `git status --porcelain --untracked-files=all -- . ":(exclude).claude"` immediately after the commit | empty | +| The same command after this artifact was written | lists exactly one path, this artifact | + +This artifact is written after the commit, so it is deliberately the single untracked path until +P8-T12 commits it. Nothing else is written between the commit and that point except the Phase 8 +artifacts, which P8-T12 also commits. + +## Host-token scan of the commit message + +| Token class | Hits | +|---|---| +| Account name (derived at run time, never written here) | 0 | +| Machine name (derived at run time, never written here) | 0 | +| Drive-rooted profile-path shape | 0 | + +## Acceptance evaluation + +- Commit exit 0. PASS +- `git status --porcelain --untracked-files=all -- . ":(exclude).claude"` lists exactly one path, + this task's artifact. PASS +- `git status --porcelain -- "*.cs" "*.csproj"` is empty. PASS +- `git diff --name-only $Base..HEAD -- "*.cs" "*.csproj"` lists exactly the 20 Write Set paths. + PASS +- The commit message contains no absolute path, account name or machine name. PASS + +## Output Summary + +Source commit `03b7bd57cacfc902a8b9f4e917ace627ffcac464` created on +`bug/utilitiescs-test-determinism-780-803-594-811`. The committed source diff against the anchor +is exactly the 20 declared paths. Working tree is clean apart from this artifact. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p8-t11-ac-reconciliation.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p8-t11-ac-reconciliation.md new file mode 100644 index 000000000..150d5994f --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p8-t11-ac-reconciliation.md @@ -0,0 +1,71 @@ +# P8-T11 — Final acceptance-criteria reconciliation + +Timestamp: 2026-09-08T10-44 +Task: [P8-T11] +Command: `Select-String` over `/spec.md` lines 292-296 for `- [x] AC` and `- [ ] AC` +EXIT_CODE: 0 + +## Observed state of spec.md lines 292-296 + +| Line | Criterion | State | +|---|---|---| +| 292 | AC1 | `- [x]` | +| 293 | AC2 | `- [x]` | +| 294 | AC3 | `- [x]` | +| 295 | AC4 | `- [ ]` | +| 296 | AC5 | `- [x]` | + +`Select-String -SimpleMatch '- [x] AC'` counts **4**; `'- [ ] AC'` counts **1**. + +The task's acceptance requires 5 and 0. It is therefore not met, and this task is left unchecked. +The counts above are the observed values; the fail-closed evidence rule makes recording the true +state the required outcome, and checklist state must not contradict evidence on disk. + +## Evidence that discharged each criterion + +| AC | Discharged by | Verdict | +|---|---|---| +| AC1 | `evidence/regression-testing/fail-before-exception.2026-09-08T09-50.md` (structural proof: `CancelAfter(` and `CreateLinkedTokenSource` both 1 to 0, positive control 0 to 1), `evidence/regression-testing/p4-t4-ac1-pass-after.md` (both dictionary tests pass; the #780 test runs in 2.76 ms), `evidence/regression-testing/p8-t6-ac4-ten-run.md` (`TryAddValuesAsync_UpdatesExistingValue` passes in all ten 24-worker runs) | DELIVERED | +| AC2 | `evidence/regression-testing/p1-t9-seam-scoped-run.md` (static seams replaced; `TableEtlInvoker` count 0 in both the production and the test file), `evidence/regression-testing/p2-t4-ac2-fail-before.md` (RED: `NullReferenceException` where `InvalidOperationException` was asserted), `evidence/regression-testing/p3-t2-ac2-pass-after.md` (GREEN after the guard, same test unchanged) | DELIVERED | +| AC3 | `evidence/regression-testing/p5-t10-ac3-pass-after.md` (six named tests pass; `Console.SetOut` totals 0 across the five converted test files; solution build clean) | DELIVERED | +| AC4 | `evidence/regression-testing/p8-t6-ac4-ten-run.md` (9 of 10 runs clean; run 7 failed one test) | **NOT DELIVERED** | +| AC5 | `evidence/qa-gates/p7-t10-ac5-timing-hack-search.md` (all eleven patterns count 0 over 737 added lines; the pre-existing `Returns(120)` tolerance retired) | DELIVERED | + +## Why AC4 is not delivered + +AC4 reads: "A full nine-assembly `/InIsolation` run with `TestCategory!=LiveOutlook` reports zero +failures on ten consecutive runs, recorded as evidence." + +Ten consecutive runs were performed and recorded. Nine reported zero failures. Run 7 reported one: + +``` +UtilitiesCS.Test.NewtonsoftHelpers.SDILReader.MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions +``` + +The criterion is a property of the whole run, not of the tests this item touches, so a single +failure anywhere defeats it regardless of cause. It is left unchecked. + +The cause is an unsynchronised process-wide static in +`UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs` raced by `ILGlobals_Tests` and +`MethodBodyReader_Tests`, neither of which carries `[DoNotParallelize]` and neither of which is in +this item's 20-path write set. Full derivation in `evidence/regression-testing/p8-t4-ac4-runs.md`. +It is a pre-existing latent defect in the same family as #811 but a distinct instance, and it needs +its own issue. Fixing it here would require editing files outside the declared write set. + +The distinction against AC1 matters and is recorded in +`evidence/other/p4-t5-ac1-checkoff-note.md`: AC1's parallel-determinism clause names one test, +which passed ten times out of ten, whereas AC4's clause covers every test in the run. + +### Acceptance Criteria Status + +- Source: `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/spec.md` +- Total AC items: 5 +- Checked off (delivered): 4 +- Remaining (unchecked): 1 +- Items remaining: AC4: A full nine-assembly `/InIsolation` run with `TestCategory!=LiveOutlook` reports zero failures on ten consecutive runs, recorded as evidence. + +## Output Summary + +Four of the five acceptance criteria are delivered and checked off in spec.md with named evidence. +AC4 is not, because one of the ten required runs failed on a defect outside this item's write set. +The three defects #811 targets did not recur in any of the ten runs. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p8-t12-final-commit.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p8-t12-final-commit.md new file mode 100644 index 000000000..916d0271c --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/p8-t12-final-commit.md @@ -0,0 +1,83 @@ +# P8-T12 — Final commit and clean-tree gate + +Timestamp: 2026-09-08T10-45 +Task: [P8-T12] +Command: the P8-T8 four counts re-run over `` and `docs/features/potential/`, then `git add -A -- . ":(exclude).claude"`, then `git commit -m "docs(811): AC4 ten-run evidence, sanitisation, follow-ups, AC reconciliation"` +EXIT_CODE: 0 + +## Sanitisation re-count + +The plan states the expected result as 0, 0, 0, 0. That holds for everything this item wrote. It +does not hold for the whole `docs/features/potential/` tree, because that tree contains 194 files, +almost all of them pre-existing and unrelated to this item. Both scopes are recorded. + +### Scope A — this item's own written set + +`` (all 47 files) plus the two potential entries P8-T9 created. + +| Check | Count | +|---|---| +| Files scanned | 49 | +| Account-token content matches | 0 | +| Machine-token content matches | 0 | +| Drive-rooted profile-path matches | 0 | +| Offending file or directory names | 0 | + +**0, 0, 0, 0**, as the plan expects. + +### Scope B — the whole `docs/features/potential/` tree + +| Check | Count | +|---|---| +| Files scanned | 194 | +| Account-token content matches | 9 | +| Machine-token content matches | 0 | +| Drive-rooted profile-path matches | 16 | + +All 25 hits lie in ten files, every one of them under `docs/features/potential/promoted/` and every +one of them present at the base commit `bb1c7d4b` (verified individually with `git cat-file -e`; +`EXISTS_AT_BASE=True` for all ten): + +``` +promoted/2026-08-10-mstest-coverage-discovery-claude-worktree-exclusion.md +promoted/2026-08-11-research-doc-cohort-library-false-negative.md +promoted/2026-08-14-orchestrator-hooks-reference-absent-python-validators.md +promoted/2026-08-14-potential-to-issue-promoted-copy-not-written.md +promoted/2026-08-26-efc-store-root-selection-leaks-full-outlook-path-into-filing-boundary.md +promoted/2026-08-26-matchbestspecialfolder-substring-matching-codified-by-tests.md +promoted/2026-08-26-taskmaster-csproj-publishurl-leaks-user-profile-path-and-org-name.md +promoted/2026-08-28-trx-evidence-host-tokens-and-malformed-xml.md +promoted/2026-08-29-parallel-run-merge-gate-misparses-pr-number.md +promoted/2026-09-02-committed-host-identity-leaks.md +``` + +These are pre-existing committed host-identity leaks in other items' promoted entries. Several of +them are documents *about* host-identity leakage, which is why they quote the offending strings. +This item does not modify any of them, and rewriting ten unrelated committed documents would be a +scope escape well outside the 20-path write set. They are left untouched and reported instead. Note +that `docs/features/potential/promoted/2026-09-02-committed-host-identity-leaks.md` already tracks +this class of problem. + +No file was rewritten by this task. + +## Intended commit message + +``` +docs(811): AC4 ten-run evidence, sanitisation, follow-ups, AC reconciliation +``` + +## Acceptance evaluation + +Recorded before the commit; the post-commit observations are reported in the executor's completion +message rather than written here, so that nothing is written after the commit. + +- The four counts are re-run over `$Feature` and `docs/features/potential/` and recorded above, + with the scope split made explicit. Scope A is 0, 0, 0, 0 as expected; scope B is non-zero + solely through pre-existing files this change does not touch. +- The intended commit message is recorded. + +## Output Summary + +Everything this item wrote is free of account tokens, machine tokens and drive-rooted profile +paths. The 25 hits in the wider potential tree are pre-existing, all in `promoted/`, all present at +the base commit, and all outside this item's write set. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/remediation-cycle-1-r1-closure.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/remediation-cycle-1-r1-closure.md new file mode 100644 index 000000000..437391b29 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/other/remediation-cycle-1-r1-closure.md @@ -0,0 +1,39 @@ +# Remediation Cycle 1 — R-1 Closure Evidence + +- Timestamp: 2026-09-08T00:00 +- Command: `git -C diff --name-only bb1c7d4b60f7b782227956f36859314d5c47bb03..HEAD -- "UtilitiesCS/**" "UtilitiesCS.Test/**"` +- EXIT_CODE: 0 +- Output Summary: New potential-entry file authored; no source-file modification introduced by this + cycle; GitHub issue creation deferred to the operator. + +## New File Authored + +- Path: `docs/features/potential/2026-09-08-ilglobals-loadopcodes-unsynchronised-static-race.md` +- Line count: 111 lines. + +## Source-File Isolation Confirmation + +`git -C diff --name-only bb1c7d4b60f7b782227956f36859314d5c47bb03..HEAD -- "UtilitiesCS/**" "UtilitiesCS.Test/**"` +returned the same 20 pre-existing source paths that #811 already modified prior to this +remediation cycle (11 under `UtilitiesCS.Test/`, 9 under `UtilitiesCS/`). No path was added to or +removed from that list by this cycle, confirming this cycle introduced no source-file +modification under `UtilitiesCS/` or `UtilitiesCS.Test/`. + +`git -C status --porcelain --untracked-files=all` immediately before staging showed +exactly five untracked paths, all documentation/evidence artifacts belonging to this cycle: +- `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/code-review.2026-09-08T11-30.md` +- `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/feature-audit.2026-09-08T11-30.md` +- `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/policy-audit.2026-09-08T11-30.md` +- `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/remediation-inputs.2026-09-08T11-30.md` +- `docs/features/potential/2026-09-08-ilglobals-loadopcodes-unsynchronised-static-race.md` + +No entry under `UtilitiesCS/` or `UtilitiesCS.Test/` appears in that status output, corroborating +the diff-based confirmation above. + +## GitHub Issue Creation — Deferred + +GitHub issue creation and promotion-tool execution were intentionally not performed in this cycle. +Reason: R-1's directive withholds issue creation as the operator's action; this remediation cycle's +scope is limited to authoring the potential-entry documentation file. The `## Next Step` promotion +checkbox in the new entry is left unchecked (`- [ ] Promote to GitHub issue (bug-report template)`) +to reflect that this half of R-1's acceptance criteria remains outstanding pending operator action. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p1-t8-seam-build.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p1-t8-seam-build.md new file mode 100644 index 000000000..8d9677fe9 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p1-t8-seam-build.md @@ -0,0 +1,55 @@ +# P1-T8 — Seam build with the analyzer gate + +Timestamp: 2026-09-08T09-37 +Task: [P1-T8] +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p1-t8.log;Verbosity=normal" +EXIT_CODE: 0 + +## Observations + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines in the file log exactly equal to ` 0 Error(s)` | `1` | +| Analyzer / compiler warning count | `0` | +| `BASELINE_ANALYZER_WARNINGS` from P0-T8 | `0` | +| Warning count does not exceed the baseline | yes (0 is not greater than 0) | + +Summary lines quoted verbatim from `coverage/msbuild-p1-t8.log`: + +``` + 0 Warning(s) + 0 Error(s) +``` + +A scan of the file log for lines matching the diagnostic shape +`: (warning|error) ` returned no lines, so there is no warning to enumerate by diagnostic id. + +## What this proves + +The build succeeded across the whole solution after Phase 1 replaced two public static properties +with optional parameters and added optional `TimeProvider?` parameters to two public members. The +in-solution callers named in spec.md therefore still compile unchanged against the new signatures: + +- `QuickFiler/Controllers/QfcDatamodel.FrameBuilding.cs:15,82-89` — calls + `GetEmailDataInViewAsync` with four positional arguments and reads the frame builder; the new + trailing `TimeProvider? timeProvider = null` is source-compatible. +- `ToDoModel/Data Model/ID/IDList.cs:130,226` — calls `FromDefaultFolder(Store, ...)`; the new + trailing optional delegate parameter is source-compatible. + +The two deleted static properties `DfDeedle.TableEtlInvoker` and `DfDeedle.StoreTableEtlInvoker` +are a public-surface removal. A surviving reader anywhere in the solution would have produced a +CS0117, and the build reports 0 errors, so every reader and writer of both properties was updated +in this same change. + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- The log contains ` 0 Error(s)`. PASS +- Warning count recorded (0) and does not exceed `BASELINE_ANALYZER_WARNINGS` (0). PASS + +## Output Summary + +Full-solution rebuild after the Phase 1 seam work: exit 0, 0 warnings, 0 errors, no analyzer +diagnostics introduced. Production behaviour is unchanged because every new parameter defaults to +null, which resolves to the system clock and to the production ETL delegate respectively. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t1-csharpier-format.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t1-csharpier-format.md new file mode 100644 index 000000000..700b33046 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t1-csharpier-format.md @@ -0,0 +1,89 @@ +# P7-T1 — Toolchain step 1 (CSharpier format) + +Timestamp: 2026-09-08T10-05 +Task: [P7-T1] +Command: dotnet tool run csharpier format . +EXIT_CODE: 0 +Toolchain pass: 3 + +REWRITTEN_COUNT: 0 + +## Why the exit code is not the signal + +`csharpier format` exits 0 whether or not it rewrote anything, and its summary line +`Formatted 1616 files in ms.` is a processed-file count, not a rewrite count: it printed 1616 +on both passes below, including the pass that rewrote five files and the pass that rewrote none. +The rewrite observation is therefore taken from the tree, as two independent measurements: + +1. SHA-256 hashes of the 19 write-set `.cs` files, taken immediately before and immediately after + the command. `REWRITTEN_COUNT` counts hash differences. +2. `git status --porcelain --untracked-files=all -- "*.cs" ":(exclude).claude"` before and after. + Any porcelain entry that appears is added to the count. This second observation exists because + an already-modified file stays marked `M` when reformatted, so porcelain alone cannot see a + rewrite of a file that was already dirty; the hash comparison is what detects that case. + +## Pass 1 (2026-09-08T10-05) — five files rewritten, loop restarted + +`REWRITTEN_COUNT: 5`, all detected by hash difference; the porcelain count was 19 before and 19 +after, so porcelain alone would have reported nothing. The five: + +``` +UtilitiesCS/HelperClasses/PrettyPrint.cs +UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs +UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs +UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs +UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs +``` + +Three of the five are new files this plan created, which is the expected case: a newly authored +file has never been through the formatter. `PrettyPrint.cs` was reformatted because the `using` +block was reordered by hand. `StackGeek_Tests.cs` was reformatted because the new +`FluentActions.Invoking(...)` chain exceeded the 100-column width and CSharpier broke it across +four lines. + +Because `REWRITTEN_COUNT` was greater than 0, the toolchain loop restarted at P7-T1 as the plan +requires. + +## Pass 2 (2026-09-08T10-05) — clean + +``` +REWRITTEN_COUNT: 0 +``` + +No hash differed and no porcelain entry appeared. `FORMAT_EXIT: 0`. + +## Pass 3 (2026-09-08T10-14) — clean, after the P7-T7 gap-closure edit + +The loop restarted a second time because P7-T6 failed its clause 2 and P7-T7 added two tests. The +format pass over that edit also rewrote nothing: + +``` +REWRITTEN_COUNT: 0 +``` + +`FORMAT_EXIT: 0`, `PRETTYPRINT_LINECOUNT: 680`. This is the final pass, and the artifact records +its state. + +## Line-cap observation + +PRETTYPRINT_LINECOUNT: 680 + +`UtilitiesCS/HelperClasses/PrettyPrint.cs` is at most 680 lines after the pass, as the acceptance +condition requires. It is exactly 680, unchanged from baseline, so CSharpier's own reformatting of +the file did not consume the zero-growth budget D10 established. + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- `REWRITTEN_COUNT: 0` on the final pass, defined as hash differences plus appeared porcelain + entries, and never read from the `Formatted N files` line. PASS +- The loop restarted at P7-T1 when `REWRITTEN_COUNT` was greater than 0, and the final artifact + shows `REWRITTEN_COUNT: 0`. PASS +- `PrettyPrint.cs` line count is at most 680 after the pass. PASS + +## Output Summary + +Three format passes. The first rewrote five files (three newly created by this plan, two +reformatted after hand edits) and triggered a loop restart. The second rewrote nothing. The third, +run after P7-T7's gap-closure edit forced a further restart, also rewrote nothing, confirming the +tree is at a CSharpier fixed point. `PrettyPrint.cs` holds at exactly 680 lines throughout. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t10-ac5-timing-hack-search.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t10-ac5-timing-hack-search.md new file mode 100644 index 000000000..25ad3e61f --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t10-ac5-timing-hack-search.md @@ -0,0 +1,95 @@ +# P7-T10 — AC5 timing-hack search over the anchored diff + +Timestamp: 2026-09-08T10-21 +Task: [P7-T10] +Command: `git diff bb1c7d4b60f7b782227956f36859314d5c47bb03 -- "*.cs" ":(exclude).claude"`, filtered to added lines (`^\+` and not `^\+\+\+`), then counted with `Select-String -SimpleMatch` and the stated regexes +EXIT_CODE: 0 + +AC5 has no automated enforcement in this repository: `BannedSymbols.txt` bans `Thread.Sleep` and +`Task.Delay` but not `CancelAfter`, `TimeoutAfter` or `WaitOne`, and `.editorconfig` holds RS0030 +at `suggestion`, so no toolchain step fails on a newly introduced sleep. This explicit search over +the diff is therefore the enforcement mechanism, as spec.md records under "AC5 enforcement, stated +honestly". + +## Scope of the search + +| Observation | Value | +|---|---| +| Total diff lines over `*.cs` (dot-claude excluded) | 1530 | +| Added lines examined | 737 | + +## Fixed-string counts over added lines + +| Pattern | Count | Verdict | +|---|---|---| +| `Thread.Sleep` | 0 | OK | +| `Task.Delay` | 0 | OK | +| `CancelAfter` | 0 | OK | +| `WaitOne` | 0 | OK | +| `Returns(120)` | 0 | OK | +| `SpinWait` | 0 | OK | +| `Stopwatch` | 0 | OK | + +`Stopwatch` counts 0 as the plan predicts: the production files' existing stopwatches +(`OlTableExtensions.Etl.cs`, `DfDeedle.cs`) are pre-existing lines, not added ones, so they do not +enter this search. + +## Regex shape counts over added lines + +| Shape | Meaning | Count | Verdict | +|---|---|---|---| +| `new CancellationTokenSource\(\s*\d` | a numeric constructor timeout | 0 | OK | +| `\.Wait\(\s*(\d\|TimeSpan)` | a timed wait | 0 | OK | +| `catch \(`, restricted to added lines in hunks of files under `UtilitiesCS.Test/` | a retry loop needs a catch | 0 | OK | +| `Returns\(\s*\d{2,}\s*\)` on a line that also contains `GetRowCount` | a mock value chosen to outrun a deadline | 0 | OK | + +The `catch (` restriction was applied by tracking the `diff --git` header of each file section and +only counting added lines while inside a section whose post-image path is under `UtilitiesCS.Test/`. +The two new test classes use `finally` only, to release their gates; neither catches anything. + +## RETIRED + +The existing timing tolerance is gone from the final tree, not merely absent from the added lines: + +| Token in `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` | Final count | +|---|---| +| `Returns(120)` | 0 | +| `cannot fire under test-host` | 0 | + +Both were 1 at the base commit (measured by P0-T6). The mock row count that was set to 120 so the +250 ms deadline could not fire is replaced by an un-advanced `FakeTimeProvider`, which controls the +clock rather than widening the deadline. + +## RETAINED_GATES + +Two occurrences of a `.Wait` member reference survive in the two new test files, and both are +release-by-test gates rather than timed waits: + +| File | Occurrence | Form | +|---|---|---| +| `DfDeedleEtlTimeoutTests.cs` | `BuildExplorer(BuildFolderWithTriageUdp(), gateA.Wait, gateB.Wait)` | two method-group references, passed as delegates | +| `OlTableExtensionsEtlClockTests.cs` | `gate.Wait,` | one method-group reference, passed as a delegate | + +Count: 2 lines, carrying 3 method-group references. Each is `ManualResetEventSlim.Wait()` with no +timeout argument, invoked inside a mock callback so that the production code blocks until the test +itself releases the gate in a `finally`. A wait with no timeout cannot mask a timing defect: it +does not bound how long the code under test may take, and the test's outcome is decided by the +injected clock, not by elapsed wall time. The `\.Wait\(\s*(\d|TimeSpan)` regex above is what would +catch a timed variant, and it counts 0. This is the same pattern the pre-existing +`DfDeedleQfcColumnTimeoutTests` already uses. + +## Acceptance evaluation + +- Every one of the eleven counts is 0. PASS +- Each pattern is listed with its count. PASS +- A separate `RETIRED:` record shows `Returns(120)` and `cannot fire under test-host` both count 0 + in the final tree of `OlTableExtensions_Tests.cs`. PASS +- A `RETAINED_GATES:` record identifies the surviving `gate.Wait` occurrences as untimed + release-by-test gates, with their count. PASS + +## Output Summary + +No sleep, delay, retry, timed wait, spin, numeric token-source timeout, or deadline-outrunning +mock value appears anywhere in the 737 added lines. The one pre-existing timing tolerance in the +touched files was retired rather than retained. AC5 is satisfied on the technique, not only on the +outcome. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t2-csharpier-check.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t2-csharpier-check.md new file mode 100644 index 000000000..f7601cf3a --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t2-csharpier-check.md @@ -0,0 +1,32 @@ +# P7-T2 — Toolchain step 1 verification (CSharpier check) + +Timestamp: 2026-09-08T10-06 +Task: [P7-T2] +Command: dotnet tool run csharpier check . +EXIT_CODE: 0 +Toolchain pass: 3 + +## Output Summary + +Summary line, verbatim: + +``` +Checked 1616 files in 7454ms. +``` + +The line begins with `Checked ` and ends with `ms.`, as the acceptance condition requires. Exit +code 0 with no path reported as unformatted. + +This is the read-only CI-parity check that pairs with the P7-T1 write-mode format pass: `format` +exits 0 whether or not it rewrote anything, while `check` exits non-zero when any file would be +rewritten. Its passing here independently confirms the `REWRITTEN_COUNT: 0` observation P7-T1 +recorded from file hashes. + +The processed-file count is 1616, three higher than the 1613 P0-T7 recorded, which is exactly the +three new `.cs` files this plan created +(`DfDeedleEtlTimeoutTests.cs`, `OlTableExtensionsEtlClockTests.cs`, `ArmingBarrierTimeProvider.cs`). + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- The summary line is quoted verbatim, begins with `Checked ` and ends with `ms.`. PASS diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t3-msbuild-analyzers.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t3-msbuild-analyzers.md new file mode 100644 index 000000000..cb4dc555d --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t3-msbuild-analyzers.md @@ -0,0 +1,46 @@ +# P7-T3 — Toolchain step 2 (msbuild analyzer gate) + +Timestamp: 2026-09-08T10-07 +Task: [P7-T3] +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p7-t3.log;Verbosity=normal" +EXIT_CODE: 0 +Toolchain pass: 3 + +## Observations + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines exactly equal to ` 0 Error(s)` | `1` | +| Warning count | `0` | +| `BASELINE_ANALYZER_WARNINGS` from P0-T8 | `0` | +| Warning count exceeds the baseline | no | +| Distinct diagnostic ids present | none | + +Summary lines quoted verbatim from `coverage/msbuild-p7-t3.log`: + +``` + 0 Warning(s) + 0 Error(s) +``` + +The acceptance condition requires that any warning present be enumerated by diagnostic id. A scan +of the log for the pattern `(warning|error) ` produced an empty id set, so there is no warning +to enumerate. The baseline ceiling of 0 is a strict one: any analyzer diagnostic this change had +introduced would have exceeded it. + +`/t:Rebuild` is used rather than `/t:Build`, so `CoreCompile` actually ran on every project and +the analyzers actually executed. A warm `/t:Build` would have returned exit 0 having skipped +compilation, and the gate could not have failed. + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- The log contains ` 0 Error(s)`. PASS +- The warning count (0) does not exceed `BASELINE_ANALYZER_WARNINGS` (0), and the empty diagnostic + id set is recorded. PASS + +## Output Summary + +Full-solution rebuild with .NET analyzers and code-style enforcement after the complete change +set: exit 0, 0 warnings, 0 errors, no diagnostic ids. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t4-msbuild-nullable.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t4-msbuild-nullable.md new file mode 100644 index 000000000..2ec4db099 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t4-msbuild-nullable.md @@ -0,0 +1,45 @@ +# P7-T4 — Toolchain step 3 (nullable / warnings-as-errors) + +Timestamp: 2026-09-08T10-07 +Task: [P7-T4] +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p7-t4.log;Verbosity=normal" +EXIT_CODE: 0 +Toolchain pass: 3 + +## Observations + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines exactly equal to ` 0 Error(s)` | `1` | +| Warning count | `0` | + +Summary lines quoted verbatim from `coverage/msbuild-p7-t4.log`: + +``` + 0 Warning(s) + 0 Error(s) +``` + +This is the gate that matters most for the eight production files in the write set, all of which +carry `#nullable enable` and are therefore opted in to nullable flow analysis, with +`/p:TreatWarningsAsErrors=true` promoting any `CS86xx` diagnostic to a build error. The change adds +a null test against a non-nullable tuple element (`tableSnapshot.data is null` in `DfDeedle.cs`), +two new nullable-annotated optional parameters (`TimeProvider? timeProvider`, `TextWriter? writer`) +and a nullable delegate parameter, and none of them produced a diagnostic. + +`/p:Nullable=enable` is not supplied, matching `.github/workflows/_build-nullable.yml`. Nullable +enforcement here is per-file opt-in through the pragma, and every file this change touches on the +production side already carries it, so no enforcement is lost. `/t:Rebuild` is used so compilation +actually runs. + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- The log contains ` 0 Error(s)`. PASS + +## Output Summary + +Full-solution rebuild with warnings treated as errors after the complete change set: exit 0, 0 +warnings, 0 errors. The new guard, the new optional parameters and the new delegate parameter all +compile clean under nullable analysis. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t5-vstest-coverage.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t5-vstest-coverage.md new file mode 100644 index 000000000..2a94a1741 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t5-vstest-coverage.md @@ -0,0 +1,110 @@ +# P7-T5 — Toolchain step 4 (final nine-assembly coverage run) + +Timestamp: 2026-09-08T10-16 +Task: [P7-T5] +Command: dotnet-coverage collect --output coverage/p7-final.cobertura.xml --output-format cobertura --settings coverage.config -- /Settings:scripts\vscode\TaskMaster.cli.runsettings /InIsolation "/Logger:trx;LogFileName=p7-t5.trx" /ResultsDirectory:coverage/trx/p7-t5 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser" +EXIT_CODE: 0 +Toolchain pass: 3 + +RERUN_COUNT: 0 + +Each execution of this task passed on its first run. No re-run-until-green was performed and none +is permitted: any failure here, including a failure of one of the two retained sentinels, would +have been a defect requiring a fix and a restart of the loop at P7-T1. + +This task ran twice, once per toolchain-loop pass. The pass-2 run recorded +`total=7160 passed=7160 failed=0`, exit 0, 76 s, `line-rate=0.8603698145941662`. P7-T6 then failed +its clause 2 on two uncovered added lines, P7-T7 added two tests to close them, and the loop +restarted at P7-T1. The figures below are the final pass-3 run, which is the one this artifact +records. + +## TRX counters (final pass) + +``` +total=7162 executed=7162 passed=7162 failed=0 error=0 timeout=0 aborted=0 notExecuted=0 +``` + +`failed` + `error` + `aborted` + `timeout` = 0. No result carried an outcome other than `Passed`. + +## Test-count delta + +| Observation | Value | +|---|---| +| P0-T10 baseline `total` | 7153 | +| P7-T5 final `total` | 7162 | +| Delta | +9 | + +The plan's stated expectation is +7 for the seven C4 tests, adjusted upward by the count of any +test P7-T7 adds. P7-T7 added two, so the adjusted expectation is +9 and the observed delta is +exactly +9. The seven C4 tests: + +1. `DictionaryExtensions_Tests.TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` +2. `DfDeedleEtlTimeoutTests.GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder` +3. `DfDeedleEtlTimeoutTests.GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame` +4. `OlTableExtensionsEtlClockTests.EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource` +5. `OlTableExtensionsEtlClockTests.EtlAsync_ClockNeverAdvances_ReturnsTransformedRows` +6. `StackGeek_Tests.Run_WritesScenarioToSuppliedWriter` +7. `PrettyPrint_Tests.PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing` + +plus the two tests P7-T7 added: + +8. `DfDeedleEtlTimeoutTests.GetEmailDataInView_NoEtlArgument_UsesProductionDefaultDelegate` +9. `OlTableExtensionsEtlClockTests.EtlAsync_NoBinaryOrObjectFields_UsesGetArrayBranchOnControlledClock` + +The delta being exactly 9 also confirms that the three renamed tests were renamed and not +duplicated, and that no test was lost when `Main_RunsSampleScenarioWithoutThrowing` was rewritten. + +## The nine C4 names + +| Test | Outcome | +|---|---| +| `TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` | `Passed` | +| `GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder` | `Passed` | +| `GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame` | `Passed` | +| `EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource` | `Passed` | +| `EtlAsync_ClockNeverAdvances_ReturnsTransformedRows` | `Passed` | +| `Run_WritesScenarioToSuppliedWriter` | `Passed` | +| `PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing` | `Passed` | +| `TryAddValuesAsync_UpdatesExistingValue` (retained #780 sentinel) | `Passed` | +| `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` (retained #803 sentinel) | `Passed` | + +None returned `ABSENT`. The two P7-T7 gap-closure tests, +`GetEmailDataInView_NoEtlArgument_UsesProductionDefaultDelegate` and +`EtlAsync_NoBinaryOrObjectFields_UsesGetArrayBranchOnControlledClock`, also both report `Passed`. + +## Root Cobertura figures + +Read from the root element of `coverage/p7-final.cobertura.xml`. + +| Attribute | Baseline (P0-T10) | Final (P7-T5 pass 3) | +|---|---|---| +| `line-rate` | `0.8601092896174863` | `0.8604239666249645` | +| `lines-covered` | `172353` | `172626` | +| `lines-valid` | `200385` | `200629` | +| `branch-rate` | `0.6630576006929406` | `0.6639784946236559` | +| `branches-covered` | `21434` | `21489` | +| `branches-valid` | `32326` | `32364` | + +Repository-wide line coverage rose from 86.011 to 86.042 percent. The full delta analysis, +including the per-file and per-changed-line gates, is in `p7-t6-coverage-delta.md`. + +## Wall clock + +RUN_SECONDS: 58 for the final pass (the pass-2 run took 76 s; the baseline run took 64 s). The +variation is run-to-run scheduling noise on a shared workstation, not a property of the change. + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- Counters recorded. PASS +- `failed` + `error` + `aborted` + `timeout` = 0, with `RERUN_COUNT: 0`. PASS +- `total` exceeds the P0-T10 `total` by exactly 9, the adjusted expectation stated above (7 C4 + tests plus the 2 tests P7-T7 added). PASS +- All six root Cobertura figures recorded as numbers. PASS +- `Read-TrxOutcome` is `Passed` for all nine C4 names. PASS + +## Output Summary + +Final full-suite coverage run: 7162 tests, 7162 passed, 0 failed, exit 0, 58 s, no re-runs. +Repository-wide line coverage 0.860424 (172626 of 200629). All nine tracked test names pass, +including both intermittent-failure sentinels this item repairs, as do the two gap-closure tests. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t6-coverage-delta.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t6-coverage-delta.md new file mode 100644 index 000000000..53b378c42 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t6-coverage-delta.md @@ -0,0 +1,92 @@ +# P7-T6 — Coverage delta + +Timestamp: 2026-09-08T10-17 +Task: [P7-T6] +Command: pwsh -NoProfile -File coverage/plan811-helper.ps1 running PROC-COV over `coverage/p0-baseline.cobertura.xml` and `coverage/p7-final.cobertura.xml` and PROC-CHANGED (`git diff --unified=0 bb1c7d4b60f7b782227956f36859314d5c47bb03 -- `) +EXIT_CODE: 0 +Toolchain pass: 3 + +All eight production files are tracked at the base commit, so the anchored diff sees every change +to them and no staging companion is needed for this task. + +## Clause 1 — per-file uncovered, final at most baseline + +| Suffix | Baseline valid/covered/uncovered | Final valid/covered/uncovered | Verdict | +|---|---|---|---| +| `UtilitiesCS\Extensions\DictionaryExtensions.cs` | 108 / 101 / 7 | 106 / 99 / 7 | OK | +| `UtilitiesCS\OutlookObjects\Table\OlTableExtensions.Etl.cs` | 297 / 271 / 26 | 296 / 284 / 12 | OK | +| `UtilitiesCS\Extensions\DfDeedle.cs` | 159 / 157 / 2 | 163 / 163 / 0 | OK | +| `UtilitiesCS\Extensions\DfDeedle.FrameUtilities.cs` | 169 / 117 / 52 | 170 / 118 / 52 | OK | +| `UtilitiesCS\OutlookObjects\Filter DASL\DASLFilterParser.cs` | 79 / 76 / 3 | 79 / 76 / 3 | OK | +| `UtilitiesCS\HelperClasses\PrettyPrint.cs` | 440 / 375 / 65 | 440 / 375 / 65 | OK | +| `UtilitiesCS\OutlookObjects\Table\OlTableExtensions.TableAccess.cs` | 270 / 215 / 55 | 271 / 216 / 55 | OK | +| `UtilitiesCS\ReusableTypeClasses\Other\StackGeek.cs` | 93 / 93 / 0 | 96 / 96 / 0 | OK | + +CLAUSE1_FAILURES: 0. No file regressed; two improved materially (`OlTableExtensions.Etl.cs` from +26 uncovered to 12, `DfDeedle.cs` from 2 to 0). + +## Clause 2 — every added executable line has hits greater than 0 + +``` +ADDED_TOTAL=76 EXECUTABLE_COVERED=38 NON_EXECUTABLE=38 UNCOVERED=0 +``` + +76 post-image added lines across the eight files. 38 carry a line node in the final Cobertura +document and all 38 report `hits` greater than 0; the other 38 have no line node and are recorded +as `hits=non-executable` (signature continuation lines, `//` and `///` comment lines, braces and +`using` directives). CLAUSE2_FAILURES: 0. + +This clause failed on the first evaluation of this task, with two uncovered added lines. Both were +closed by P7-T7 and this is the post-closure measurement; see `p7-t7-gap-closure.md`. + +## Clause 3 — `StackGeek.Run` line rate + +RUN_LINE_RATE: 1 + +`Get-MethodLineRate` for suffix `UtilitiesCS\ReusableTypeClasses\Other\StackGeek.cs`, method +`Run`, returns 1. `Run` is a new method extracted by P5-T4, so CLAUDE.md UT2's 90 percent floor +for new methods applies; 100 percent clears it. The rate is a number, not `ABSENT`, so the method +element was found and the measurement is not vacuous. + +## Clause 4 — `EtlAsync` span rate + +| | Span | Rate | +|---|---|---| +| Baseline (P0-T11) | 66-130 | 35/50 = 0.7000 | +| Final | 66-132 | 48/49 = 0.9796 | + +The final span was read from the edited file by the rule the plan states: the line containing +`> EtlAsync(` through the first later line that is exactly eight spaces followed by `}`. The final +rate 0.9796 is at least the baseline 0.7000, so the clause passes with a wide margin. + +The improvement is the point of the change: the `catch (TimeoutException)` block was entirely +uncovered at baseline (lines 117-123 of the pre-change file) and is now exercised by +`OlTableExtensionsEtlClockTests.EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource`, +and the `GetArray` branch's `TimeoutAfter` is exercised by +`EtlAsync_NoBinaryOrObjectFields_UsesGetArrayBranchOnControlledClock`. + +ETLASYNC_UNCOVERED_FINAL: 96 — a single remaining uncovered line in the span, down from fifteen. + +## Clause 5 — repository-wide comparison + +COMPARABILITY: A + +| Observation | Value | +|---|---| +| Baseline `lines-valid` | 200385 | +| Final `lines-valid` | 200629 | +| Denominator delta | 0.1218 percent | +| Baseline `line-rate` | 0.860109289617486 | +| Final `line-rate` | 0.860423966624964 | +| Branch A gate: final at least baseline minus 0.005 | True | + +The denominators differ by 0.1218 percent, which is within the 1 percent band, so branch A applies +and the rate comparison is gated. Final line-rate 0.86042 is above baseline 0.86011, so coverage +improved rather than regressed and the gate passes without needing the tolerance. + +## Output Summary + +All six clauses pass. Per-file uncovered counts held or improved on all eight production files; +all 38 added executable lines are covered; the new `Run` method is at line rate 1; the `EtlAsync` +span rose from 0.7000 to 0.9796; and repository-wide line coverage rose from 0.860109 to 0.860424 +on a denominator that moved 0.12 percent (COMPARABILITY: A, gate satisfied). diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t7-gap-closure.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t7-gap-closure.md new file mode 100644 index 000000000..d6b505970 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t7-gap-closure.md @@ -0,0 +1,83 @@ +# P7-T7 — Coverage gap closure + +Timestamp: 2026-09-08T10-17 +Task: [P7-T7] +Command: two tests added, then the toolchain loop restarted at P7-T1 and P7-T5 and P7-T6 re-run +EXIT_CODE: 0 + +GAP CLOSURE: REQUIRED AND COMPLETED + +The first evaluation of P7-T6 failed clause 2 with two uncovered added lines. Both are now +covered. + +## The two gaps and the tests that closed them + +### 1. `UtilitiesCS/Extensions/DfDeedle.cs:70` — the `DefaultTableEtl` lambda body + +The added line is `> DefaultTableEtl = t => ((Outlook.Table)t).ETL();`, the production ETL delegate +P1-T3 introduced to replace the deleted `TableEtlInvoker` static. Its lambda body executes only +when `GetEmailDataInView` is called with no `etl` argument. Every pre-existing test of that method +passes an injected delegate, so the production default was never invoked and the line reported +zero hits. + +Test added, in `UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs` (the write-set test file +that owns `DfDeedle.cs` per this task's ownership rule): + +`UtilitiesCS.Test.Extensions.DfDeedleEtlTimeoutTests.GetEmailDataInView_NoEtlArgument_UsesProductionDefaultDelegate` + +It calls `DfDeedle.GetEmailDataInView(explorer.Object)` with no `etl` argument over the same mock +Explorer the class already builds, and asserts the frame has one row. This is the delegate +counterpart of the null-writer default tests added for AC3: it proves the optional parameter's +default reproduces the previous behaviour, which is exactly the invariant spec.md requires of every +new optional parameter. + +### 2. `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs:116` — the `GetArray` branch deadline + +The added line is `.TimeoutAfter(milliseconds, timeProvider);` on the `else` branch of `EtlAsync`, +taken when the column set contains no `BinaryToStringFields` member and no object converter +matches. It is an added line because P1-T1 changed its second argument from `attempts` to +`timeProvider`. The corresponding pre-change line was also uncovered at baseline (P0-T11 recorded +line 114 among the fifteen uncovered lines of the `EtlAsync` span), so this branch has never had a +test. + +Test added, in `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` (the +write-set test file that owns `OlTableExtensions.Etl.cs`): + +`UtilitiesCS.Test.OutlookObjects.Table.OlTableExtensionsEtlClockTests.EtlAsync_NoBinaryOrObjectFields_UsesGetArrayBranchOnControlledClock` + +It supplies columns `EntryID` and `Subject` — neither is in +`MAPIFields.BinaryToStringFields`, whose members are `ConversationIndex`, `ConversationId` and +`Store` — and passes a null converter dictionary, so the branch condition is false on both +disjuncts and the `GetArray` path is taken. The clock is an un-advanced `FakeTimeProvider`, so the +250 ms deadline is armed but cannot fire. The test asserts the transformed array and that the token +source was not cancelled. + +Neither test uses a sleep, a retry, a timed wait, or a mock value chosen to outrun a deadline; both +are covered by the P7-T10 AC5 search over the added lines. + +## Loop restart + +Adding the two tests required restarting the toolchain loop at P7-T1, which was done. Pass 3 +recorded: `P7T1_REWRITTEN_COUNT: 0`, `P7T2_CHECK_EXIT: 0`, `P7T3_EXIT: 0` with ` 0 Error(s)` and +0 warnings, `P7T4_EXIT: 0` with ` 0 Error(s)` and 0 warnings. + +## Re-run results + +`p7-t5-vstest-coverage.md` (re-run): `total` 7162, `passed` 7162, `failed` 0, exit 0. The delta +over the P0-T10 baseline of 7153 is +9: the seven C4 tests plus these two gap-closure tests. Both +new tests report `Passed`. + +`p7-t6-coverage-delta.md` (re-run): all six clauses pass. +`ADDED_TOTAL=76 EXECUTABLE_COVERED=38 NON_EXECUTABLE=38 UNCOVERED=0`. + +## Acceptance evaluation + +- The artifact exists and records the outcome. PASS +- The added test names are listed, fully qualified. PASS +- A passing re-run of P7-T5 and P7-T6 accompanies them. PASS + +## Output Summary + +Two coverage gaps closed with two tests, both placed in the write-set test file that owns the +production file. Clause 2 moved from 2 uncovered added lines to 0. The toolchain loop was restarted +and completed clean on pass 3. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t8-line-cap.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t8-line-cap.md new file mode 100644 index 000000000..cb23729b5 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t8-line-cap.md @@ -0,0 +1,66 @@ +# P7-T8 — File-size audit + +Timestamp: 2026-09-08T10-19 +Task: [P7-T8] +Command: `(Get-Content -LiteralPath ).Count` for the 19 write-set `.cs` files, after the final format pass +EXIT_CODE: 0 + +## Measured line counts + +| File | Lines | Cap | Verdict | +|---|---|---|---| +| `UtilitiesCS/Extensions/DictionaryExtensions.cs` | 281 | 500 | OK | +| `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` | 476 | 500 | OK | +| `UtilitiesCS/Extensions/DfDeedle.cs` | 319 | 500 | OK | +| `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs` | 279 | 500 | OK | +| `UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs` | 123 | 500 | OK | +| `UtilitiesCS/HelperClasses/PrettyPrint.cs` | 680 | at most 680 (pre-existing violation) | OK | +| `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs` | 432 | 500 | OK | +| `UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs` | 206 | 500 | OK | +| `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs` | 318 | 500 | OK | +| `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` | 848 | strictly below 869 (pre-existing violation) | OK | +| `UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs` | 228 | 500 | OK | +| `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` | 234 | 500 | OK | +| `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` | 453 | strictly below 500 | OK | +| `UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs` | 55 | 500 | OK | +| `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs` | 279 | 500 | OK | +| `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs` | 418 | 500 | OK | +| `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs` | 109 | 500 | OK | +| `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` | 1846 | at most 1848 (pre-existing violation) | OK | +| `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs` | 110 | 500 | OK | + +## Not touched + +| File | Lines | Expected | +|---|---|---| +| `UtilitiesCS/Threading/TimeOutTask.cs` | 1011 | exactly 1011 (untouched) | + +`TimeOutTask.cs` is byte-identical to the base commit; P7-T9's numstat over it is empty. + +## Project file + +`UtilitiesCS.Test/UtilitiesCS.Test.csproj` is 988 lines. It is recorded as exempt: the 500-line +cap in the General Code Change Policy governs production code, test code and reusable script +files, and does not reach `*.csproj`. Its growth is exactly three `` items, from +985 in the merged base tree to 988. + +## Acceptance evaluation + +- Every file other than the three pre-existing violations is at most 500 lines. The largest is + `OlTableExtensions.Etl.cs` at 476. PASS +- `UtilitiesCS/HelperClasses/PrettyPrint.cs` is at most 680 (it is exactly 680, unchanged). PASS +- `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` is strictly below 869 (848, a reduction of + 21). PASS +- `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` is at most 1848 (1846, a + reduction of 9). PASS +- `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` is strictly below 500 (453, a + reduction of 47 achieved by moving `ArmingBarrierTimeProvider` out). PASS +- `UtilitiesCS/Threading/TimeOutTask.cs` is exactly 1011. PASS +- The project file is recorded as exempt from the cap. PASS + +## Output Summary + +No new cap violation was introduced. All three pre-existing violations shrank or held: +`DfDeedle_COM_Tests.cs` 869 to 848, `OlTableExtensions_Tests.cs` 1855 to 1846, `PrettyPrint.cs` +680 to 680. `DfDeedleQfcColumnTimeoutTests.cs`, which was at exactly the 500-line cap and could not +grow, dropped to 453. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t9-write-set-diff.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t9-write-set-diff.md new file mode 100644 index 000000000..d97244e15 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p7-t9-write-set-diff.md @@ -0,0 +1,82 @@ +# P7-T9 — Write-set scope audit + +Timestamp: 2026-09-08T10-19 +Task: [P7-T9] +Command: `git add --intent-to-add -- `, then `git diff --name-only bb1c7d4b60f7b782227956f36859314d5c47bb03 -- "*.cs" "*.csproj" "*.config" "*.props" "*.targets" "*.runsettings" "*.yml" ":(exclude).claude"`, then `git status --porcelain --untracked-files=all -- . ":(exclude).claude"`, then `git diff --numstat bb1c7d4b60f7b782227956f36859314d5c47bb03 -- UtilitiesCS/Threading/TimeOutTask.cs UtilitiesCS/Extensions/DfDeedle.QfcColumns.cs` +EXIT_CODE: 0 + +The `--intent-to-add` staging is what makes the three new files visible to an anchored +`--name-only` diff, which otherwise enumerates tracked changes only and would report them as +absent. The `:(exclude).claude` pathspec is applied to every span because the dot-claude +agent-memory tree is tracked in this repository and may be dirty from preparation agents. + +## Anchored name-only diff — exactly the 20 Write Set paths + +``` +UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs +UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs +UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs +UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs +UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs +UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs +UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs +UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs +UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs +UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs +UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs +UtilitiesCS.Test/UtilitiesCS.Test.csproj +UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs +UtilitiesCS/Extensions/DfDeedle.cs +UtilitiesCS/Extensions/DictionaryExtensions.cs +UtilitiesCS/HelperClasses/PrettyPrint.cs +UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs +UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs +UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs +UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs +``` + +20 paths. Sorted, this list equals the plan's 20-path Write Set exactly: + +- **No fewer.** Every one of the 20 declared paths is touched. Items 1 to 8 are the production + files, 9 to 19 the test files, 20 the project file. +- **No more.** No path outside the set appears. In particular no `.github/workflows/` file, no + `packages.config`, no `.runsettings`, no `.editorconfig` and no `BannedSymbols.txt` was touched, + and the diff span explicitly includes the `*.config`, `*.props`, `*.targets`, `*.runsettings` + and `*.yml` globs so that a change to any of them would have appeared. + +## Scoped porcelain status + +The porcelain output lists the same 20 source paths — three as `A` (the intent-to-added new files) +and seventeen as `M` — plus, under `/`, the modified `plan.2026-09-07T22-03.md` and +`spec.md` and 27 untracked evidence artifacts. No path outside the 20 plus `/` appears. + +`docs/features/potential/` is empty at this point; P8-T9 has not yet run. The C1 helper script +`coverage/plan811-helper.ps1` does not appear because `coverage/*` is gitignored. + +## Untouched-file control + +``` +git diff --numstat bb1c7d4b60f7b782227956f36859314d5c47bb03 -- UtilitiesCS/Threading/TimeOutTask.cs UtilitiesCS/Extensions/DfDeedle.QfcColumns.cs +``` + +Output is empty. Both files are byte-identical to the base commit, which confirms two deliberate +non-goals held: + +- The inert `(int, int)` `TimeoutAfter` overloads in `TimeOutTask.cs` were not deleted (D9; the + file is 1011 lines and already over the cap, and the overloads retain two test callers). +- `DfDeedle.QfcColumns.cs` was not edited, so its line-96 doc comment still names the now-deleted + `TableEtlInvoker` and is stale. That is recorded as a follow-up in P8-T9 rather than fixed here, + because touching the file would widen the write set. + +## Acceptance evaluation + +- The name-only list, sorted, equals exactly the 20 Write Set paths: none missing, none extra. + PASS +- The porcelain output lists no path outside the 20 paths plus `/` plus + `docs/features/potential/`. PASS +- `git diff --numstat` over `TimeOutTask.cs` and `DfDeedle.QfcColumns.cs` is empty. PASS + +## Output Summary + +The change is exactly the declared 20-path write set. No scope escape. Both files the plan +declared untouched are byte-identical to the base commit. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p8-t8-sanitisation.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p8-t8-sanitisation.md new file mode 100644 index 000000000..8ea4310a4 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/qa-gates/p8-t8-sanitisation.md @@ -0,0 +1,58 @@ +# P8-T8 — Host-token sanitisation sweep + +Timestamp: 2026-09-08T10-42 +Task: [P8-T8] +Command: pwsh -NoProfile -File coverage/plan811-helper.ps1 — `Select-String` over every file under `/evidence` for the run-time-derived account and machine tokens and the drive-rooted profile-path shape, plus a name scan over the same tree +EXIT_CODE: 0 + +The account and machine tokens are derived at run time +(`[regex]::Escape((Split-Path -Leaf $env:USERPROFILE))` and +`[regex]::Escape($env:COMPUTERNAME)`) and their values are never written into this artifact. + +## Counts + +| Check | Count | Verdict | +|---|---|---| +| Files scanned under `/evidence` | 40 | — | +| Content matches for the account token (case-insensitive) | 0 | OK | +| Content matches for the machine token (case-insensitive) | 0 | OK | +| Content matches for the drive-rooted profile-path shape | 0 | OK | +| File or directory names containing either token | 0 | OK | + +All four final counts are 0. + +## Substitutions performed + +None. No file required rewriting, so the per-file substitution count is 0 for every file. This is +the observation, not the exit code: the rewrite step exits 0 whether or not it changes anything, +so the counts above are what establish the result. + +Two artifacts quote redacted stack traces that originally contained absolute worktree paths, and +both were redacted at authoring time rather than by this sweep: + +- `evidence/regression-testing/p2-t4-ac2-fail-before.md` — the `NullReferenceException` trace, with + the worktree path written as ``. +- `evidence/regression-testing/p8-t4-ac4-runs.md` — the `MethodBodyReader_Tests` failure trace, + likewise written as ``. + +`evidence/baseline/p0-t5-tool-resolution.md` writes the Visual Studio installation root as +`` and the user profile root as `` for the same reason. + +## Coverage of this sweep + +The sweep covers every artifact produced by P0-T1 through P8-T7, which is the 40 files counted +above. The artifacts produced by P8-T9 through P8-T11 are written after this task from plan text +only and are re-counted by P8-T12, which re-runs these same four checks over `$Feature` and +`docs/features/potential/`. + +## Acceptance evaluation + +- All four final counts are 0. PASS +- The artifact never writes either token's value. PASS +- The sweep covers every artifact produced by P0-T1 through P8-T7, and P8-T12 re-counts the later + ones. PASS + +## Output Summary + +40 evidence files scanned. No account name, machine name, drive-rooted profile path, or offending +file name found. No substitution was required. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/fail-before-exception.2026-09-08T09-50.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/fail-before-exception.2026-09-08T09-50.md new file mode 100644 index 000000000..977c07218 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/fail-before-exception.2026-09-08T09-50.md @@ -0,0 +1,90 @@ +# Fail-before exception dossier — AC1 (issue #780) + +Timestamp: 2026-09-08T09-50 +Task: [P4-T3] +Acceptance criterion: AC1 +EXIT_CODE: 0 + +## Why a failing run is impossible + +WhyFailingRunImpossible: The #780 failure is load-dependent thread-pool scheduling latency above 500 ms, and the only ways to force it on demand are a sleep or a deliberately starved thread pool, both forbidden by AC5 and by the repository unit-test policy. A test built either way would also be nondeterministic in the opposite direction, passing or failing with the same host load the defect depends on. + +The #780 failure is thread-pool scheduling latency exceeding 500 ms under a 24-worker coverage +run, so it is load-dependent rather than deterministic: `TryAddValuesAsync_UpdatesExistingValue` +passes in about 2 ms when run alone and fails intermittently only under full-suite parallel load. +The only ways to force the latency on demand are a sleep or a deliberately starved thread pool, +and AC5 forbids stabilising or reproducing behaviour with a sleep, a retry, or a timing tolerance, +as does the repository unit-test policy's ban on wall-clock waits in test code. A test that waited +for the pool to fall behind would also be nondeterministic in the opposite direction: it would +pass or fail depending on the same host load the defect depends on. No deterministic failing run +is therefore constructible, and this dossier stands in its place. + +This is recorded as decision D6 in the plan and is a deviation from the spec Test Strategy's +"token cancelled after the work is observed to start" bullet, which is not authored: `TryAddValues` +offers no observation point without a custom operator type, and such a test would pass identically +before and after the fix, so it would carry no discriminating power. + +## Alternative proof + +### 1. Structural count gate on the deleted window + +The defect is a hard-coded wall-clock cancellation window in production code. Its presence and its +removal are both directly measurable, and the measurement is exact rather than probabilistic. + +| Token in `UtilitiesCS/Extensions/DictionaryExtensions.cs` | Before P4-T1 | After P4-T1 | +|---|---|---| +| `CancelAfter(` | 1 | 0 | +| `CreateLinkedTokenSource` | 1 | 0 | +| `TryAddValues(key, value), token)` | 0 | 1 | + +The "before" figures are the ones P0-T6 measured against the base tree `bb1c7d4b` and recorded in +`evidence/baseline/p0-t6-citation-baseline.md`; both matched the plan's stated values exactly. The +"after" figures were measured by P4-T1 against the edited file. The third row is the positive +control: it shows the caller's own token, rather than a linked token carrying an internal deadline, +is now what `Task.Run` receives. + +The deleted lines were: + +```csharp +var linkedTS = CancellationTokenSource.CreateLinkedTokenSource(token); +linkedTS.CancelAfter(500); +``` + +With `CancelAfter(` at 0 occurrences, no fixed wall-clock window remains in the method, which is +AC1's first disjunct discharged by construction rather than by observation. A future reintroduction +would raise the count above 0 and is additionally caught by the P7-T10 AC5 diff search, which +counts `CancelAfter` over the added lines of the anchored diff. + +Secondary defect also resolved: `linkedTS` was never disposed, so every call leaked a timer until +it fired. With the linked source gone, nothing is leaked. + +### 2. Contract-lock test + +Deleting a cancellation window could in principle weaken the surviving cancellation contract. It +does not, and P4-T2 adds a test that fails if it ever does: +`UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` +asserts that a caller token cancelled before the call still yields `TaskCanceledException` from +`Task.Run` and that the dictionary value is unchanged. Its passing run is recorded in +`p4-t4-ac1-pass-after.md`. + +### 3. Zero production call sites + +`TryAddValuesAsync` has no production caller. The only invocation anywhere in the repository is +`UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs:244`; the other two search hits are the +definition at `DictionaryExtensions.cs:169` and the test method name at line 237. This is decision +D3, and it is why deletion rather than a `TimeProvider` seam is the correct fix: the window guarded +nothing, could not interrupt a `Task.Run` body already running, and protected a bounded +compare-and-swap loop (`TryAddValues`, lines 123-136) that performs no I/O and cannot hang. + +## Negative evidence record + +SearchScope: `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/` +SearchPatterns: `fail-before-exception.*.md` +SearchResult: `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/fail-before-exception.2026-09-08T09-50.md` (this file) + +## Output Summary + +AC1 has no deterministic failing run, for a stated and verifiable reason. The alternative proof is +a three-part structural argument: an exact before-and-after count gate on the deleted window +(1 to 0 for both tokens, with a positive control at 0 to 1), a contract-lock test for the +cancellation semantics that survive, and the absence of any production call site. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p1-t9-seam-scoped-run.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p1-t9-seam-scoped-run.md new file mode 100644 index 000000000..a6a4be65a --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p1-t9-seam-scoped-run.md @@ -0,0 +1,60 @@ +# P1-T9 — Scoped run of every class the seams touch + +Timestamp: 2026-09-08T09-38 +Task: [P1-T9] +Command: UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p1-t9.trx" /ResultsDirectory:coverage/trx/p1-t9 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:FullyQualifiedName~DfDeedle_COM_Tests|FullyQualifiedName~DfDeedleQfcColumnTimeoutTests|FullyQualifiedName~OlTableExtensions_Tests|FullyQualifiedName~TimeOutTask_Tests" +EXIT_CODE: 0 + +The filter uses `FullyQualifiedName~` joined with `|` rather than the full-run filter, +because a run scoped to `UtilitiesCS.Test.dll` alone cannot reach the repository's only +`[TestCategory("LiveOutlook")]` test, which lives in `TaskMaster.Test`. + +## TRX counters + +``` +total=179 executed=179 passed=179 failed=0 error=0 timeout=0 aborted=0 notExecuted=0 +``` + +No result carried an outcome other than `Passed`. + +## Named outcomes + +| Class | Method | Outcome | +|---|---|---| +| `UtilitiesCS.Test.Extensions.DfDeedle_COM_Tests` | `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` | `Passed` | +| `UtilitiesCS.Test.Extensions.DfDeedleQfcColumnTimeoutTests` | `AddQfcColumnsAsync_ThreeDeadlines_InvokesColumnAdderExactlyOnce` | `Passed` | + +The second outcome is the load-bearing one for P1-T6: it proves the `ArmingBarrierTimeProvider` +still drives the #798 deadline tests correctly after being moved out of +`DfDeedleQfcColumnTimeoutTests.cs` into the shared +`UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs`. The move was a pure extraction with +no member-body change, and `NewSignal` still resolves from `ColumnAdderProbe` at the source file's +former lines 95 and 103 through the added `using UtilitiesCS.Test.TestHelpers;`. + +`TimeOutTask_Tests` is included in the filter because +`UtilitiesCS.Test/Threading/TimeOutTask_Tests.cs:197,210` still call the inert `(int, int)` +`TimeoutAfter` overloads, which this change deliberately leaves in place (D9, spec non-goal). The +class passing proves those untouched overloads still bind after the call sites in +`OlTableExtensions.Etl.cs` and `DfDeedle.cs` moved to the `(int, TimeProvider?)` overload. + +## Blame hang guard + +SEQUENCE_FILES: 0 + +No `Sequence` file was written under `coverage/trx/p1-t9`, so the 4-minute blame hang timeout did +not fire on any test. + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- Counters recorded. PASS +- `failed` = 0. PASS +- `Read-TrxOutcome` returned `Passed` for both named tests. Neither returned `ABSENT`, so both + were discovered and executed. PASS + +## Output Summary + +179 tests across the four seam-affected classes, all passed, exit 0. Both named sentinel tests +passed. The Phase 1 seam work introduced no behaviour change detectable by the existing suite, +which is the intended outcome: every new parameter defaults to the value that reproduces the +previous behaviour. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p2-t3-red-build.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p2-t3-red-build.md new file mode 100644 index 000000000..cc7ba8682 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p2-t3-red-build.md @@ -0,0 +1,71 @@ +# P2-T3 — Build the test project to produce the assembly + +Timestamp: 2026-09-08T09-43 +Task: [P2-T3] +Command: msbuild UtilitiesCS.Test\UtilitiesCS.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU /nologo /v:q "/flp:LogFile=coverage/msbuild-p2-t3.log;Verbosity=normal" +EXIT_CODE: 0 + +This is a project-file build used only to produce a test assembly for the scoped P2-T4 run. It is +not a gate, so it uses `/p:Platform=AnyCPU` without the space, which is the project-level spelling; +the solution-level alias `"/p:Platform=Any CPU"` is used by the two solution gates. + +## Final observations + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines in the file log exactly equal to ` 0 Error(s)` | `1` | +| Warning count | `0` | +| `Test-Path UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll` | `True` | +| Assembly `LastWriteTime` | `2026-09-08T09:43:46` | +| Task start | `2026-09-08T09:43:35` | +| Assembly is newer than the task start | `True` | + +Summary lines quoted verbatim from `coverage/msbuild-p2-t3.log`: + +``` + 0 Warning(s) + 0 Error(s) +``` + +The `LastWriteTime` comparison is the non-vacuity control: it proves the run produced a fresh +assembly containing the new test class rather than leaving an earlier build in place. + +## Two compile errors were fixed inside this task + +The first two invocations of this task's command failed. Both are mechanical compile fixes to the +file P2-T1 created, made within this task because the task's stated outcome is a building +assembly. Neither changes the test's behaviour, its arrangement, or any P2-T1 acceptance count, +and both were re-verified against the full P2-T1 count set afterwards. + +1. **`CS0104` on `Action` (2 occurrences, exit 1).** `'Action' is an ambiguous reference between + 'Microsoft.Office.Interop.Outlook.Action' and 'System.Action'`. The file carries + `using Microsoft.Office.Interop.Outlook;`, which introduces an Outlook `Action` COM type. + Fixed by qualifying the two gate-delegate parameters of the private `BuildExplorer` helper as + `System.Action`, which is the same spelling + `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` already uses for its own gate + delegate field. +2. **`CS0246` on `SilentProgressTracker` (1 occurrence, exit 1).** + `SilentProgressTracker` is a `private sealed` nested class of `DfDeedle_COM_Tests`, so it is + not visible from another class. The plan's P2-T1 text specifies a `ProgressTracker` over + `SilentProgressTracker` as part of the builders replicated from `DfDeedle_COM_Tests`; the + replication was completed by adding the same nested class to `DfDeedleEtlTimeoutTests`, with + the three no-op `Report` overrides unchanged. + +Post-fix re-verification of every P2-T1 acceptance count: `[TestMethod]`=2, +`ArmingBarrierTimeProvider`=2, `Advance(250)`=1, `ThrowAsync`=1, +`Thread.Sleep`=0, `Task.Delay`=0, `CancelAfter`=0, `WaitOne`=0, timed-wait regex=0, line count +209 (cap 300). All pass. + +## Acceptance evaluation + +- `EXIT_CODE: 0`. PASS +- The log contains ` 0 Error(s)`. PASS +- `UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll` exists and its `LastWriteTime` is later than + the task start. PASS + +## Output Summary + +Test project builds clean after two mechanical compile fixes (Outlook `Action` ambiguity, and a +private nested `SilentProgressTracker` that had to be replicated rather than referenced). Fresh +assembly produced at 09:43:46, 11 seconds after the task started. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p2-t4-ac2-fail-before.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p2-t4-ac2-fail-before.md new file mode 100644 index 000000000..4ed3da165 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p2-t4-ac2-fail-before.md @@ -0,0 +1,91 @@ +# P2-T4 — AC2 fail-before evidence [expect-fail] + +Timestamp: 2026-09-08T09-45 +Task: [P2-T4] +Command: UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p2-t4.trx" /ResultsDirectory:coverage/trx/p2-t4 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:FullyQualifiedName~DfDeedleEtlTimeoutTests" +EXIT_CODE: 1 +ExpectedExitCode: 1 + +A failing test is the expected and required outcome of this task. The paired pass-after record is +`p3-t2-ac2-pass-after.md`, produced after P3-T1 inserts the guard. + +## TRX counters + +`coverage/trx/p2-t4/p2-t4.trx` exists. + +``` +total=2 executed=2 passed=1 failed=1 error=0 timeout=0 aborted=0 notExecuted=0 +``` + +Expected: `total`=2, `passed`=1, `failed`=1. Observed exactly that. + +## Named outcomes + +| Method | Expected | Observed | +|---|---|---| +| `GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder` | `Failed` | `Failed` | +| `GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame` | `Passed` | `Passed` | + +Neither returned `ABSENT`, so both tests were discovered and executed: the new file is registered +in the project file and compiled into the assembly. + +## Failure message, verbatim after redaction + +The worktree path is redacted to `` and the account name to ``; nothing else is +altered. + +``` +Expected a to be thrown, but found : +System.NullReferenceException: Object reference not set to an instance of an object. + at UtilitiesCS.DfDeedle.d__8.MoveNext() in \UtilitiesCS\Extensions\DfDeedle.cs:line 179 +--- End of stack trace from previous location where exception was thrown --- + at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw() + at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task) + at FluentAssertions.Specialized.AsyncFunctionAssertions`2.d__15.MoveNext() in /_/Src/FluentAssertions/Specialized/AsyncFunctionAssertions.cs:line 373. +``` + +The TRX text contains `System.NullReferenceException` inside that result's `ErrorInfo/Message`; +FluentAssertions reports the found exception type, which is exactly the discriminating signal the +acceptance condition asks for. A test that merely asserted `Should().Throw()` would +have passed here and proved nothing. + +## The reported line is the defect site + +`DfDeedle.cs:179` in the current tree is the opening line of the `LogDfTiming(` invocation whose +dereference `tableSnapshot.Item1.GetLength(0)` sits on line 181. This is the same statement the CI +stack trace reported as `DfDeedle.cs:186` in the pre-change tree; the site moved from 186 to 179 +because P1-T3 deleted 14 lines of static-seam declarations above it and P1-T2 added 4 lines below +that. The .NET sequence point for a multi-line invocation is the line the statement opens on, +which is why both traces name the opening line rather than the dereference line. + +The mechanism is the one the research artifact derived: the 250 ms ETL hop deadline expires on the +injected clock, `EtlByRowAsync` throws `TimeoutException`, `EtlAsync` swallows it at its +`catch (TimeoutException)` block, `data` stays null, and `return (data!, columnDictionary)` forces +that null through a null-forgiving suppression into a non-nullable tuple element. The guard P3-T1 +adds goes between lines 178 and 179, ahead of the whole call. + +## Blame hang guard + +SEQUENCE_FILES: 0 + +No `Sequence` file was written under `coverage/trx/p2-t4`, so the 4-minute blame hang timeout did +not fire. The test reached its assertion and failed on the assertion, rather than hanging: this is +what confirms the D5 arming order held and that the clock was advanced only after the 250 ms timer +existed. + +## Acceptance evaluation + +- `EXIT_CODE: 1` with `ExpectedExitCode: 1`. PASS +- Counters `total`=2, `passed`=1, `failed`=1. PASS +- `Read-TrxOutcome` for the expiry test is `Failed` and its `ErrorInfo/Message` contains + `System.NullReferenceException`. PASS +- `Read-TrxOutcome` for the green test is `Passed`. PASS +- No `Sequence` file under the results directory, so the blame hang timeout did not fire. PASS +- The failure message is quoted verbatim after redaction. PASS + +## Output Summary + +RED established for AC2. The new deadline-expiry test fails with `NullReferenceException` where it +asserts `InvalidOperationException`, at the exact production site the issue reports. The companion +green-path test passes on the same un-advanced clock, which shows the failure is caused by the +expired deadline and not by the test arrangement. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p3-t2-ac2-pass-after.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p3-t2-ac2-pass-after.md new file mode 100644 index 000000000..7feb186fa --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p3-t2-ac2-pass-after.md @@ -0,0 +1,83 @@ +# P3-T2 — AC2 pass-after evidence + +Timestamp: 2026-09-08T09-48 +Task: [P3-T2] +Command: msbuild UtilitiesCS.Test\UtilitiesCS.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU /nologo /v:q "/flp:LogFile=coverage/msbuild-p3-t2.log;Verbosity=normal" ; then UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p3-t2.trx" /ResultsDirectory:coverage/trx/p3-t2 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:FullyQualifiedName~DfDeedleEtlTimeoutTests|FullyQualifiedName~DfDeedle_COM_Tests" +EXIT_CODE: 0 + +Paired fail-before record: `p2-t4-ac2-fail-before.md`. + +## Build + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines exactly equal to ` 0 Error(s)` | `1` | + +``` + 0 Warning(s) + 0 Error(s) +``` + +## Test run + +| Observation | Value | +|---|---| +| Test run exit code | `0` | + +``` +total=28 executed=28 passed=28 failed=0 error=0 timeout=0 aborted=0 notExecuted=0 +``` + +`failed` = 0. No result carried an outcome other than `Passed`. + +## Named outcomes + +| Class | Method | P2-T4 (before) | P3-T2 (after) | +|---|---|---|---| +| `DfDeedleEtlTimeoutTests` | `GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder` | `Failed` | `Passed` | +| `DfDeedleEtlTimeoutTests` | `GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame` | `Passed` | `Passed` | +| `DfDeedle_COM_Tests` | `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` | not in scope of that run | `Passed` | + +The first row is the RED-to-GREEN transition AC2 requires. The only change between the two runs is +P3-T1's guard: no test text, no mock, and no deadline value was altered. The test still asserts the +exception type explicitly and the folder name through `.WithMessage("*Inbox*")`, so a regression to +`NullReferenceException`, or to an exception that does not name the folder, would fail it rather +than pass a generic assertion. + +The third row is the #803 sentinel, which now runs with an un-advanced `FakeTimeProvider`, so every +deadline on its path is armed on a clock that does not move and cannot fire under host load. + +## Note on the P3-T1 `#nullable enable` acceptance clause + +P3-T1's acceptance lists `#nullable enable`=1 for `DfDeedle.cs`. The measured substring count is 2, +and it was also 2 at the base commit `bb1c7d4b` (verified with +`git grep -c -F '#nullable enable' bb1c7d4b -- UtilitiesCS/Extensions/DfDeedle.cs`, which returns +2). The second hit is line 157, a pre-existing #798 comment reading +"file's #nullable enable while the validator parameter is a non-nullable string." — it is prose, +not a directive, and this change does not touch it. + +The clause was therefore resolved on its discriminator rather than on the stated number: the +`#nullable enable` directive itself occurs on exactly one line (a whole-line regex match returns +1, at line 23), and the substring count is identical before and after this change, so the edit +altered the file's nullable state in no way. Satisfying the literal count of 1 would have required +deleting an unrelated pre-existing comment, which is outside the write set's intent and is not +requested anywhere in the plan. This is recorded as a plan-citation defect for the orchestrator; +it changes no acceptance criterion and no other gate depends on the figure. P0-T6 did not assert +this count, so the halt gate had no opportunity to catch it. + +## Acceptance evaluation + +- Build `EXIT_CODE: 0` with ` 0 Error(s)`. PASS +- Test `EXIT_CODE: 0`. PASS +- `failed` = 0. PASS +- `Read-TrxOutcome` is `Passed` for both `DfDeedleEtlTimeoutTests` methods and for + `DfDeedle_COM_Tests` / `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform`. + PASS +- The paired fail-before record is named: `p2-t4-ac2-fail-before.md`. PASS + +## Output Summary + +28 tests across the two `DfDeedle` classes, all passed, exit 0. The AC2 deadline-expiry test moved +from `Failed` with `NullReferenceException` to `Passed` with the descriptive +`InvalidOperationException` naming the folder, with the guard as the only intervening change. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p4-t4-ac1-pass-after.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p4-t4-ac1-pass-after.md new file mode 100644 index 000000000..974c2aa5d --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p4-t4-ac1-pass-after.md @@ -0,0 +1,75 @@ +# P4-T4 — AC1 pass-after evidence + +Timestamp: 2026-09-08T09-52 +Task: [P4-T4] +Command: msbuild UtilitiesCS.Test\UtilitiesCS.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU /nologo /v:q "/flp:LogFile=coverage/msbuild-p4-t4.log;Verbosity=normal" ; then UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p4-t4.trx" /ResultsDirectory:coverage/trx/p4-t4 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:FullyQualifiedName~DictionaryExtensions_Tests" +EXIT_CODE: 0 + +Paired fail-before record: `fail-before-exception.2026-09-08T09-50.md` (AC1 has no deterministic +failing run; see that dossier for why, and for the structural alternative proof). + +## Build + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines exactly equal to ` 0 Error(s)` | `1` | + +``` + 0 Warning(s) + 0 Error(s) +``` + +## Test run + +| Observation | Value | +|---|---| +| Test run exit code | `0` | + +``` +total=15 executed=15 passed=15 failed=0 error=0 timeout=0 aborted=0 notExecuted=0 +``` + +`failed` = 0. + +## Named outcomes + +| Method | Outcome | +|---|---| +| `TryAddValuesAsync_UpdatesExistingValue` | `Passed` | +| `TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` | `Passed` | + +The first is the #780 test, retained unchanged: no assertion was weakened, no tolerance added, and +no attribute applied to serialise it. It now passes because the production defect it was tripping +over is gone, not because the test was adjusted. + +The second is the new contract-lock test, which is what keeps the deletion honest: removing a +cancellation window could in principle have removed cancellation altogether, and this test fails if +a pre-cancelled caller token ever stops producing `TaskCanceledException` or if the value is +mutated despite cancellation. + +## Duration of the #780 test + +DURATION_UPDATES: `00:00:00.0027633` + +Read from that result's `UnitTestResult/@duration`. 2.76 ms, which is under one second as the +acceptance requires, and is consistent with the roughly 2 ms the issue reports for an isolated +run. The failing runs recorded in #780 took about 20 s for this same test, because the 500 ms +window fired and the `await` observed a cancellation after the pool eventually scheduled the work. +With the window deleted there is no wall-clock deadline left in the method for load to exceed. + +## Acceptance evaluation + +- Build exit 0 with ` 0 Error(s)`. PASS +- Test `EXIT_CODE: 0`. PASS +- `failed` = 0. PASS +- `Read-TrxOutcome` is `Passed` for both named methods. PASS +- The duration of `TryAddValuesAsync_UpdatesExistingValue` read from `@duration` is under one + second (2.76 ms). PASS + +## Output Summary + +15 tests in `DictionaryExtensions_Tests`, all passed, exit 0. The retained #780 test runs in +2.76 ms and the new pre-cancelled-token contract test passes, so the cancellation semantics that +survive the deletion are locked by an assertion rather than assumed. AC1's "passes deterministically +under 24-worker parallel coverage runs" clause is evidenced separately by the P8-T6 ten-run gate. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p5-t10-ac3-pass-after.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p5-t10-ac3-pass-after.md new file mode 100644 index 000000000..5332fcdde --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p5-t10-ac3-pass-after.md @@ -0,0 +1,109 @@ +# P5-T10 — AC3 pass-after evidence + +Timestamp: 2026-09-08T09-59 +Task: [P5-T10] +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p5-t10.log;Verbosity=normal" ; then UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p5-t10.trx" /ResultsDirectory:coverage/trx/p5-t10 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:FullyQualifiedName~StackGeek_Tests|FullyQualifiedName~PrettyPrint_Tests|FullyQualifiedName~DASLFilterParserTests|FullyQualifiedName~OlTableExtensions_Tests|FullyQualifiedName~NLogTraceWriter_Test|FullyQualifiedName~Triage_OlLogicTests" +EXIT_CODE: 0 + +## Build + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines exactly equal to ` 0 Error(s)` | `1` | +| Warning count | `0` (baseline ceiling from P0-T8 is 0) | +| Diagnostic lines matching `: (warning\|error) ` | none | + +``` + 0 Warning(s) + 0 Error(s) +``` + +This proves the two out-of-project callers of the seamed members compile unchanged: +`TaskMaster/Ribbon/TryFunctionalityInConstruction.cs:51,66` (which calls `EnumerateTable()` and +`df.PrettyPrint()`) and +`UtilitiesCS/EmailIntelligence/ClassifierGroups/Triage/Triage_OlLogic.cs:74` (which calls +`parser.PrintTree(logicTree, 0)`). All three new parameters are optional and trailing, so the +existing call sites bind to the same members with the writer defaulted to `Console.Out`. + +## D10 substitution decision + +D10_SUBSTITUTION_TRIGGERED: False + +The build log contains no `CS0103` or `CS0246` diagnostic naming `PrettyPrint.cs`, so the D10 +conditional substitution did not apply: `using Svg;` stays deleted and +`using System.Windows.Input;` is retained. The three removals chosen by D10 +(`using System.Threading.Tasks;` at old line 6, the duplicate `using System.Text;` at old line 12, +and `using Svg;` at old line 18) were each verified by a token probe before deletion — `Task` +occurred only on its own using line, `Svg` only on its own using line, and `System.Text` appeared +at both line 5 and line 12 — and all three were confirmed unused by this clean build. + +PRETTYPRINT_LINECOUNT: 680 + +`PrettyPrint.cs` is exactly 680 lines, unchanged from baseline. The zero-net-growth budget held: +`+1` for `using System.IO;`, `+2` for the two overloads wrapping to two lines each under +CSharpier's 100-column width, and `-3` for the three removals. + +## Test run + +| Observation | Value | +|---|---| +| Test run exit code | `0` | + +``` +total=139 executed=139 passed=139 failed=0 error=0 timeout=0 aborted=0 notExecuted=0 +``` + +`failed` = 0. No result carried an outcome other than `Passed`. + +## The six named outcomes + +| Method | Outcome | +|---|---| +| `PrintTree_WritesIndentedTreeToSuppliedWriter` (renamed) | `Passed` | +| `DataFramePrettyHelpers_RenderRowsMarkdownAndWriterOutput` (renamed) | `Passed` | +| `EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart` (renamed) | `Passed` | +| `Main_RunsSampleScenarioWithoutThrowing` | `Passed` | +| `Run_WritesScenarioToSuppliedWriter` (new) | `Passed` | +| `PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing` (new) | `Passed` | + +None returned `ABSENT`. `Triage_OlLogicTests` is included in the filter because +`Triage_OlLogicTests.cs:142` already exercises the null-writer `PrintTree` path, which is why no +extra null-writer test was added for DASL; it passed as part of the 139. + +## Console.SetOut elimination + +CONSOLE_SETOUT_TOTAL: 0 + +`Select-String -SimpleMatch 'Console.SetOut'` over the five test files this phase touched +(`StackGeek_Tests.cs`, `PrettyPrint_Tests.cs`, `DASLFilterParserTests.cs`, +`OlTableExtensions_Tests.cs`, `NLogTraceWriter_Test.cs`) totals 0. Every capture-and-assert site +now writes to a test-owned `StringWriter` passed as an argument, so no test in this assembly +depends on process-wide `Console.Out`, and the roughly 24 unrestored `DebugTextWriter` aggressors +can no longer break any assertion. + +Three of the four victim classes lost `[DoNotParallelize]` entirely +(`StackGeek_Tests`, `PrettyPrint_Tests`, `DASLFilterParserTests`), so the AC4 ten-run gate now +exercises the seam rather than the serialization stopgap. `OlTableExtensions_Tests` retains the +attribute under D8 and spec Mitigation 4, with its comment rewritten so it no longer claims the +console as the reason; that retention is filed as a follow-up in P8-T9. + +`NLogTraceWriter_Test`, the propagator, lost its `Console.Out` save/install/restore altogether: it +asserts on a mock logger and never on console text, so the `DebugTextWriter` install had no purpose +and its `[TestCleanup]` could install a foreign writer as the process-wide `Console.Out` for the +remainder of the run. + +## Acceptance evaluation + +- Build exit 0 with ` 0 Error(s)`. PASS +- Test `EXIT_CODE: 0`. PASS +- `failed` = 0. PASS +- `Read-TrxOutcome` is `Passed` for all six named tests. PASS +- `Select-String -SimpleMatch 'Console.SetOut'` over the five test files totals 0. PASS + +## Output Summary + +Full-solution rebuild clean (0 warnings, 0 errors) and 139 tests across the six filtered classes +all passing. The four `TextWriter` seams are in place, the shared-console dependency is eliminated +from every capture-and-assert site, and `PrettyPrint.cs` held at exactly 680 lines with no D10 +substitution required. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p6-t5-etl-clock-pass.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p6-t5-etl-clock-pass.md new file mode 100644 index 000000000..333879e03 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p6-t5-etl-clock-pass.md @@ -0,0 +1,89 @@ +# P6-T5 — ETL clock tests and tolerance retirement + +Timestamp: 2026-09-08T10-04 +Task: [P6-T5] +Command: msbuild UtilitiesCS.Test\UtilitiesCS.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU /nologo /v:q "/flp:LogFile=coverage/msbuild-p6-t5.log;Verbosity=normal" ; then UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p6-t5.trx" /ResultsDirectory:coverage/trx/p6-t5 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:FullyQualifiedName~OlTableExtensions_Tests|FullyQualifiedName~OlTableExtensionsEtlClockTests" +EXIT_CODE: 0 + +## Build + +| Observation | Value | +|---|---| +| Build exit code | `0` | +| Count of lines exactly equal to ` 0 Error(s)` | `1` | +| Warning count | `0` | + +``` + 0 Warning(s) + 0 Error(s) +``` + +## Test run + +| Observation | Value | +|---|---| +| Test run exit code | `0` | + +``` +total=85 executed=85 passed=85 failed=0 error=0 timeout=0 aborted=0 notExecuted=0 +``` + +`failed` = 0. No result carried an outcome other than `Passed`. + +## Named outcomes + +| Class | Method | Outcome | +|---|---|---| +| `OlTableExtensionsEtlClockTests` | `EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource` | `Passed` | +| `OlTableExtensionsEtlClockTests` | `EtlAsync_ClockNeverAdvances_ReturnsTransformedRows` | `Passed` | +| `OlTableExtensions_Tests` | `EtlAsync_WithBinaryAndObjectFieldsAndProgress_ReturnsTransformedData` | `Passed` | +| `OlTableExtensions_Tests` | `EtlByRowAsync_PrivateHelper_ReturnsConvertedRows` | `Passed` | + +None returned `ABSENT`, so the newly registered file was compiled into the assembly and both of +its tests were discovered. + +The third row is the test whose `Returns(120)` timing tolerance P6-T1 retired. It now runs with +the natural row count of 1, so its deadline is the real 250 ms rather than 30 000 ms, and it is +kept deterministic by an un-advanced `FakeTimeProvider` instead of by a mock value chosen to +outrun the deadline. That is the AC5-compliant replacement: the deadline is not widened, it is +placed under the test's control. + +The fourth row is the reflection-binding test named in P6-T2 (its method is +`EtlByRowAsync_PrivateHelper_ReturnsConvertedRows`, and its `InvokeStaticAsync` call now sits at +lines 1122-1135). Its passing run is the behavioural confirmation of the static check P6-T2 +recorded: the four-type array still resolves the unchanged four-parameter overload. + +## The two new tests + +`EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource` documents the surviving `EtlAsync` +contract rather than changing it: on expiry the `TimeoutException` is swallowed, `data` comes back +null through the null-forgiving suppression, and `tokenSource` is cancelled. This is the first +test in the repository to cover the `catch (TimeoutException)` block, which P0-T11 measured as +entirely uncovered at baseline (lines 117-123 of the pre-change file). + +`EtlAsync_ClockNeverAdvances_ReturnsTransformedRows` is the paired green path on an un-advanced +clock, asserting `columnInfo["Store"]` is 1 and `data[0, 1]` is the injected binary string. + +## Blame hang guard + +SEQUENCE_FILES: 0 + +No `Sequence` file under `coverage/trx/p6-t5`, so the 4-minute blame hang timeout did not fire. +The gated deadline test reached its assertions rather than hanging, which confirms the timer was +armed before the clock advanced. + +## Acceptance evaluation + +- Build exit 0 with ` 0 Error(s)`. PASS +- Test `EXIT_CODE: 0`. PASS +- `failed` = 0. PASS +- `Read-TrxOutcome` is `Passed` for both `OlTableExtensionsEtlClockTests` methods, for + `EtlAsync_WithBinaryAndObjectFieldsAndProgress_ReturnsTransformedData`, and for the reflection + test, whose method name is recorded above as `EtlByRowAsync_PrivateHelper_ReturnsConvertedRows`. + PASS + +## Output Summary + +85 tests across the two table classes, all passed, exit 0. The `Returns(120)` timing tolerance is +retired and replaced by a controlled clock, and the previously uncovered ETL deadline-expiry path +now has a deterministic test. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t1-ac4-runs.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t1-ac4-runs.md new file mode 100644 index 000000000..d9aee31e5 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t1-ac4-runs.md @@ -0,0 +1,70 @@ +# P8-T1 — AC4 runs 1 and 2 + +Timestamp: 2026-09-08T10-26 +Task: [P8-T1] +Command: /EnableCodeCoverage /InIsolation "/Logger:trx;LogFileName=p8-t1-run.trx" /ResultsDirectory:coverage/trx/p8-t1 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser" +EXIT_CODE: 0 +EXIT_CODE (run 1): 0 + +AC4_COMMAND_SHAPE: CI-VERBATIM, read from `p0-t12-ci-shape-probe.md`. No `/Settings:` argument is +passed, so parallelism comes only from `[assembly: Parallelize(Workers = 0, Scope = ClassLevel)]` +at `UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21`, exactly as in CI. + +## Tree state + +| Observation | Value | +|---|---| +| `git rev-parse HEAD` | `03b7bd57cacfc902a8b9f4e917ace627ffcac464` | +| Equals `SOURCE-HEAD` from P7-T12 | `True` | +| `git status --porcelain -- "*.cs" "*.csproj"` entry count | 0 | + +The ten runs are consecutive on one unchanged tree; no source file is edited between them. + +## Counters + +| Run | total | executed | passed | failed | error | aborted | timeout | notExecuted | seconds | +|---|---|---|---|---|---|---|---|---|---| +| 1 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 0 | 70.9 | +| 2 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 0 | 54.2 | + +Both `total` values equal the P7-T5 `total` of 7162. Both exit codes are 0. No result in either run +carried an outcome other than `Passed`. + +## Non-vacuity control (run 1) + +All nine C4 names read `Passed` from `p8-t1-run1.trx`. An unregistered or undiscovered test would +read `ABSENT`, which would fail this clause; none did. + +| Test | Outcome | +|---|---| +| `TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` | `Passed` | +| `GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder` | `Passed` | +| `GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame` | `Passed` | +| `EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource` | `Passed` | +| `EtlAsync_ClockNeverAdvances_ReturnsTransformedRows` | `Passed` | +| `Run_WritesScenarioToSuppliedWriter` | `Passed` | +| `PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing` | `Passed` | +| `TryAddValuesAsync_UpdatesExistingValue` | `Passed` | +| `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` | `Passed` | + +## `.coverage` cleanup + +4 `*.coverage` files found under `coverage/trx/p8-t1` (two per run), all deleted after the counters +were read. 0 remain. They are large binaries and are never committed. + +## Local worker count + +`[Environment]::ProcessorCount` is 24, which is what `Workers = 0` resolves to on this workstation. + +## Acceptance evaluation + +- Both TRX files exist. PASS +- Both runs report `failed`=0, `error`=0, `aborted`=0, `timeout`=0, and `executed`=`total`. PASS +- Both `total` values equal the P7-T5 `total` (7162). PASS +- Both exit codes are 0. PASS +- `Read-TrxOutcome` on `p8-t1-run1.trx` is `Passed` for all nine C4 names. PASS + +## Output Summary + +AC4 runs 1 and 2 of 10: 7162 tests each, all passed, both exit 0, 70.9 s and 54.2 s. The +non-vacuity control confirms all nine tracked tests were discovered and executed. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t2-ac4-runs.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t2-ac4-runs.md new file mode 100644 index 000000000..b7ed210d7 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t2-ac4-runs.md @@ -0,0 +1,42 @@ +# P8-T2 — AC4 runs 3 and 4 + +Timestamp: 2026-09-08T10-29 +Task: [P8-T2] +Command: /EnableCodeCoverage /InIsolation "/Logger:trx;LogFileName=p8-t2-run.trx" /ResultsDirectory:coverage/trx/p8-t2 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser" +EXIT_CODE: 0 +EXIT_CODE (run 3): 0 + +AC4_COMMAND_SHAPE: CI-VERBATIM. No `/Settings:` argument. + +## Tree state + +| Observation | Value | +|---|---| +| `git rev-parse HEAD` | `03b7bd57cacfc902a8b9f4e917ace627ffcac464` | +| Equals `SOURCE-HEAD` from P7-T12 | `True` | +| `git status --porcelain -- "*.cs" "*.csproj"` entry count | 0 | + +## Counters + +| Run | total | executed | passed | failed | error | aborted | timeout | notExecuted | seconds | +|---|---|---|---|---|---|---|---|---|---| +| 3 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 0 | 55.0 | +| 4 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 0 | 51.9 | + +Both `total` values equal the P7-T5 `total` of 7162. Both exit codes are 0. No result carried an +outcome other than `Passed`. + +## `.coverage` cleanup + +4 files found, all deleted, 0 remain. + +## Acceptance evaluation + +- Both TRX files exist. PASS +- Both runs report `failed`=0, `error`=0, `aborted`=0, `timeout`=0, `executed`=`total`. PASS +- Both `total` values equal the P7-T5 `total`. PASS +- Both exit codes are 0. PASS + +## Output Summary + +AC4 runs 3 and 4 of 10: 7162 tests each, all passed, both exit 0, 55.0 s and 51.9 s. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t3-ac4-runs.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t3-ac4-runs.md new file mode 100644 index 000000000..d241c0aae --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t3-ac4-runs.md @@ -0,0 +1,42 @@ +# P8-T3 — AC4 runs 5 and 6 + +Timestamp: 2026-09-08T10-31 +Task: [P8-T3] +Command: /EnableCodeCoverage /InIsolation "/Logger:trx;LogFileName=p8-t3-run.trx" /ResultsDirectory:coverage/trx/p8-t3 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser" +EXIT_CODE: 0 +EXIT_CODE (run 5): 0 + +AC4_COMMAND_SHAPE: CI-VERBATIM. No `/Settings:` argument. + +## Tree state + +| Observation | Value | +|---|---| +| `git rev-parse HEAD` | `03b7bd57cacfc902a8b9f4e917ace627ffcac464` | +| Equals `SOURCE-HEAD` from P7-T12 | `True` | +| `git status --porcelain -- "*.cs" "*.csproj"` entry count | 0 | + +## Counters + +| Run | total | executed | passed | failed | error | aborted | timeout | notExecuted | seconds | +|---|---|---|---|---|---|---|---|---|---| +| 5 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 0 | 51.3 | +| 6 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 0 | 70.7 | + +Both `total` values equal the P7-T5 `total` of 7162. Both exit codes are 0. No result carried an +outcome other than `Passed`. + +## `.coverage` cleanup + +4 files found, all deleted, 0 remain. + +## Acceptance evaluation + +- Both TRX files exist. PASS +- Both runs report `failed`=0, `error`=0, `aborted`=0, `timeout`=0, `executed`=`total`. PASS +- Both `total` values equal the P7-T5 `total`. PASS +- Both exit codes are 0. PASS + +## Output Summary + +AC4 runs 5 and 6 of 10: 7162 tests each, all passed, both exit 0, 51.3 s and 70.7 s. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t4-ac4-runs.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t4-ac4-runs.md new file mode 100644 index 000000000..86e5749b0 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t4-ac4-runs.md @@ -0,0 +1,126 @@ +# P8-T4 — AC4 runs 7 and 8 + +Timestamp: 2026-09-08T10-34 +Task: [P8-T4] +Command: /EnableCodeCoverage /InIsolation "/Logger:trx;LogFileName=p8-t4-run.trx" /ResultsDirectory:coverage/trx/p8-t4 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser" +EXIT_CODE: 0 +EXIT_CODE (run 7): 1 + +## VERDICT: ACCEPTANCE NOT MET + +Run 7 reported one failing test. The pair acceptance requires `failed`=0 on both runs, so this +task does not pass and is left unchecked in the plan. No re-run was performed: re-running until +green is what AC4 exists to prevent, and doing it here would destroy the evidence. + +## Tree state + +| Observation | Value | +|---|---| +| `git rev-parse HEAD` | `03b7bd57cacfc902a8b9f4e917ace627ffcac464` | +| Equals `SOURCE-HEAD` from P7-T12 | `True` | +| `git status --porcelain -- "*.cs" "*.csproj"` entry count | 0 | + +The tree is unchanged from the source commit, so the failure is not attributable to an edit +between runs. + +## Counters + +| Run | total | executed | passed | failed | error | aborted | timeout | notExecuted | seconds | +|---|---|---|---|---|---|---|---|---|---| +| 7 | 7162 | 7162 | 7161 | **1** | 0 | 0 | 0 | 0 | 73.9 | +| 8 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 0 | 69.4 | + +## The failing test + +``` +UtilitiesCS.Test.NewtonsoftHelpers.SDILReader.MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions +``` + +Failure message, verbatim after redaction: + +``` +Expected bodyCode "0000 : nop +0001 : 1879067923 +0006 : stloc.0 +0007 : br.s 0009 +0009 : ldloc.0 +0010 : ret +" to contain "ldstr". + at UtilitiesCS.Test.NewtonsoftHelpers.SDILReader.MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions() in \UtilitiesCS.Test\NewtonsoftHelpers\SDILReader\MethodBodyReader_Tests.cs:line 74 +``` + +Duration 0.12 s. All nine C4 names still read `Passed` on this run. + +## This is a different defect, in files outside the write set + +The failing test is not one of the nine C4 names, and it is not one of the three failure modes +this item repairs. Neither the test file +(`UtilitiesCS.Test/NewtonsoftHelpers/SDILReader/MethodBodyReader_Tests.cs`) nor the production +file it exercises (`UtilitiesCS/NewtonsoftHelpers/SDIL Reader/MethodBodyReader.cs` and +`ILGlobals.cs`) appears anywhere in the 20-path write set, and neither was touched by this change. + +## Root cause, established by reading + +`ILGlobals` holds unsynchronised process-wide mutable statics: + +``` +UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs:112 public static Dictionary Cache = new Dictionary(); +UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs:117 public static OpCode[] multiByteOpCodes = null!; +UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs:118 public static OpCode[] singleByteOpCodes = null!; +UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs:121 public static void LoadOpCodes() +UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs:123 singleByteOpCodes = new OpCode[0x100]; +``` + +`LoadOpCodes()` first reassigns `singleByteOpCodes` to a freshly allocated, all-default array and +then fills it in a loop. There is no lock, no `Lazy`, and no `volatile`. + +Two test classes call it, and **neither carries `[DoNotParallelize]`**, so under the assembly's +class-level parallel scope they can execute concurrently on different workers: + +- `UtilitiesCS.Test/NewtonsoftHelpers/SDILReader/ILGlobals_Tests.cs` calls `ILGlobals.LoadOpCodes()` + at lines 15, 26, 37 and 47. +- `UtilitiesCS.Test/NewtonsoftHelpers/SDILReader/MethodBodyReader_Tests.cs` calls it at line 364. + +When `MethodBodyReader.ConstructInstructions` reads +`ILGlobals.singleByteOpCodes[(int)value]` (line 105) while a sibling class is between the +reassignment and the fill, it receives `default(OpCode)`. `default(OpCode)` has an empty `Name` +and `OperandType` 0, which is `InlineBrTarget`, so the switch at line 119 takes the branch +`metadataToken = ReadInt32(...); metadataToken += position; instruction.Operand = metadataToken;` +and stores a raw integer instead of resolving the string through +`module.ResolveString(metadataToken)` at line 192. + +The observed output is exactly that signature: the line reads `0001 : 1879067923` — two spaces +where the opcode name should be (the empty `Name` of `default(OpCode)`) followed by a raw token +value rather than `ldstr "hello"`. + +## Attribution + +This is a **pre-existing latent race**, not one introduced by this change: + +- Both racing classes were already parallel-eligible before this change; neither is in the write + set and neither gained or lost a `[DoNotParallelize]` attribute here. +- The static state involved is `ILGlobals`, which this change does not touch. + +Stated conservatively: this change did move three classes (`StackGeek_Tests`, `PrettyPrint_Tests`, +`DASLFilterParserTests`) from the sequential phase into the parallel set, which raises the parallel +density of the run and can therefore alter the probability that any latent race surfaces. It does +not create this race, and it does not make these two classes newly concurrent with each other. + +The defect was not observed in the P0-T10 or P0-T12 baseline runs, but those are two single runs of +an intermittently failing test and do not establish absence. + +## Acceptance evaluation + +- Both TRX files exist. PASS +- Both runs report `failed`=0. **FAIL** — run 7 reported `failed`=1. +- Both `total` values equal the P7-T5 `total` (7162). PASS +- Both exit codes are 0. **FAIL** — run 7 exited 1. + +## Output Summary + +AC4 runs 7 and 8 of 10. Run 8 was clean; run 7 failed one test, +`MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions`, through an unsynchronised +static-state race in `ILGlobals` between two test classes that are both outside this item's write +set. The task's acceptance is not met and it remains unchecked. Consequence: the AC4 ten-run gate +cannot report zero failures on ten consecutive runs, so AC4 is not checked off. Recorded for +follow-up triage. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t5-ac4-runs.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t5-ac4-runs.md new file mode 100644 index 000000000..da548d495 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t5-ac4-runs.md @@ -0,0 +1,43 @@ +# P8-T5 — AC4 runs 9 and 10 + +Timestamp: 2026-09-08T10-39 +Task: [P8-T5] +Command: /EnableCodeCoverage /InIsolation "/Logger:trx;LogFileName=p8-t5-run.trx" /ResultsDirectory:coverage/trx/p8-t5 /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None "/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser" +EXIT_CODE: 0 +EXIT_CODE (run 9): 0 + +AC4_COMMAND_SHAPE: CI-VERBATIM. No `/Settings:` argument. + +## Tree state + +| Observation | Value | +|---|---| +| `git rev-parse HEAD` | `03b7bd57cacfc902a8b9f4e917ace627ffcac464` | +| Equals `SOURCE-HEAD` from P7-T12 | `True` | +| `git status --porcelain -- "*.cs" "*.csproj"` entry count | 0 | + +## Counters + +| Run | total | executed | passed | failed | error | aborted | timeout | notExecuted | seconds | +|---|---|---|---|---|---|---|---|---|---| +| 9 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 0 | 54.2 | +| 10 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 0 | 58.8 | + +Both `total` values equal the P7-T5 `total` of 7162. Both exit codes are 0. No result carried an +outcome other than `Passed`. + +## `.coverage` cleanup + +4 files found, all deleted, 0 remain. + +## Acceptance evaluation + +- Both TRX files exist. PASS +- Both runs report `failed`=0, `error`=0, `aborted`=0, `timeout`=0, `executed`=`total`. PASS +- Both `total` values equal the P7-T5 `total`. PASS +- Both exit codes are 0. PASS + +## Output Summary + +AC4 runs 9 and 10 of 10: 7162 tests each, all passed, both exit 0, 54.2 s and 58.8 s. This pair +passes its own acceptance; the ten-run gate as a whole does not, because run 7 (P8-T4) failed. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t6-ac4-ten-run.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t6-ac4-ten-run.md new file mode 100644 index 000000000..1aa02d619 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/p8-t6-ac4-ten-run.md @@ -0,0 +1,120 @@ +# P8-T6 — AC4 ten-run aggregate + +Timestamp: 2026-09-08T10-39 +Task: [P8-T6] +Command: PROC-TRX `Read-TrxCounters` and `Times/@start`,`@finish` over the ten TRX files under `coverage/trx/p8-t1` through `coverage/trx/p8-t5` +EXIT_CODE: 0 + +## VERDICT: 9 of 10 runs clean. AC4 IS NOT SATISFIED. + +AC4 requires zero failures on ten consecutive runs. Run 7 reported one failure, so the criterion +is not met and AC4 is left unchecked in spec.md. P8-T7, the AC4 check-off task, is therefore not +performed. + +## The ten runs + +| Run | total | executed | passed | failed | error | aborted | timeout | seconds | +|---|---|---|---|---|---|---|---|---| +| 1 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 70.9 | +| 2 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 54.2 | +| 3 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 55.0 | +| 4 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 51.9 | +| 5 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 51.3 | +| 6 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 70.7 | +| 7 | 7162 | 7162 | **7161** | **1** | 0 | 0 | 0 | 73.9 | +| 8 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 69.4 | +| 9 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 54.2 | +| 10 | 7162 | 7162 | 7162 | 0 | 0 | 0 | 0 | 58.8 | + +Exactly ten rows. All ten `total` values are identical at 7162, matching the P7-T5 `total`. +Wall-clock times range from 51.3 s to 73.9 s. Total gate duration about 10 minutes. + +## The one failure + +Run 7 (`coverage/trx/p8-t4/p8-t4-run1.trx`): + +``` +UtilitiesCS.Test.NewtonsoftHelpers.SDILReader.MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions +Expected bodyCode "0000 : nop +0001 : 1879067923 +... +" to contain "ldstr". +``` + +It is caused by an unsynchronised process-wide static in +`UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs`: `LoadOpCodes()` reassigns +`singleByteOpCodes` to a fresh all-default array before filling it, and two test classes that both +call it (`ILGlobals_Tests` and `MethodBodyReader_Tests`) carry no `[DoNotParallelize]`, so a reader +can observe the array mid-initialisation. Full derivation in `p8-t4-ac4-runs.md`. + +This is a **different defect** from the three this item repairs, in files outside the 20-path write +set, and it is pre-existing: neither racing class was touched by this change and neither gained or +lost a parallelism attribute. It is recorded for follow-up triage rather than fixed here, because +fixing it would require editing files outside the declared write set. + +## The three repaired failure modes did not recur + +This was checked exhaustively rather than sampled: 13 tracked tests were read from each of the ten +TRX files by `Read-TrxOutcome`, giving 130 assertions, and **all 130 returned `Passed`**. None +returned `ABSENT`, `NO-RESULT`, `Failed` or `NotExecuted`. The 13 are the nine C4 names plus the +four AC3 conversions (`DataFramePrettyHelpers_RenderRowsMarkdownAndWriterOutput`, +`PrintTree_WritesIndentedTreeToSuppliedWriter`, +`EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart`, +`Main_RunsSampleScenarioWithoutThrowing`). + +Across all ten runs, every one of the nine C4 names read `Passed`, including the two sentinels that +motivated this item: + +- `DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` (#780) +- `DfDeedle_COM_Tests.GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` (#803, #594 item 1) + +and the four `Console.Out` victim tests (#594 items 2 and 3), three of which now run without +`[DoNotParallelize]`. On the evidence of ten consecutive runs, the three defects this change +targets did not recur. + +## Provenance and load + +| Observation | Value | +|---|---| +| `SOURCE-HEAD` recorded by all five pair tasks | `03b7bd57cacfc902a8b9f4e917ace627ffcac464` (identical in all five) | +| `AC4_COMMAND_SHAPE` | CI-VERBATIM | +| Local worker count (`[Environment]::ProcessorCount`) | 24 | + +`Workers = 0` in `UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21` resolves to the processor +count, so these runs used 24 class-level workers. + +## Filter deviation, stated honestly + +The filter used is: + +``` +TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser +``` + +The three `FullyQualifiedName!~` clauses beyond `TestCategory!=LiveOutlook` are a documented local +environmental exclusion, not a property of this change: one of the 23 shell-icon tests fails per +run on this workstation with an invalid Win32 icon handle, independently of this fix, per the +`SHELL_ICON_EXCLUSION: REQUIRED` verdict in the #798 baseline evidence. Those classes are covered +by CI, which runs them unfiltered. + +The CI run on the pull request supplies the unfiltered single-run form. One CI run is not ten, and +ten local runs are not the unfiltered form; both facts are stated here because neither alone +discharges AC4. + +## Acceptance evaluation + +- Exactly ten rows. PASS +- Every `failed`, `error`, `aborted`, `timeout` is 0. **FAIL** — run 7 has `failed`=1. +- Every `passed` equals its `total`. **FAIL** — run 7 has `passed`=7161, `total`=7162. +- All ten `total` values are identical and equal the P7-T5 `total`. PASS +- The five `SOURCE-HEAD` values are identical. PASS +- The artifact contains no account name, machine name or absolute path. Verified by P8-T8. PASS +- `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` present. PASS + +## Output Summary + +Ten consecutive full nine-assembly `/InIsolation` runs on the unchanged source commit +`03b7bd57`, 7162 tests each, 24 workers. Nine runs were completely clean; run 7 failed one test +through a pre-existing unsynchronised-static race in `ILGlobals`, which lies outside this item's +write set. The three defects this item repairs did not recur in any of the ten runs. AC4's literal +condition of zero failures on ten consecutive runs is not met, so AC4 remains unchecked. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/feature-audit.2026-09-08T11-30.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/feature-audit.2026-09-08T11-30.md new file mode 100644 index 000000000..48617a897 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/feature-audit.2026-09-08T11-30.md @@ -0,0 +1,254 @@ +# Feature Audit — utilitiescs-test-determinism (Issue #811) + +- Date: 2026-09-08T11-30 +- Branch: `bug/utilitiescs-test-determinism-780-803-594-811` +- Head: `3805ca89f0c72e2bb448726695f18f420bd260f7` +- Base: `bb1c7d4b60f7b782227956f36859314d5c47bb03` +- Work mode: `full-bug` (marker at `issue.md` line 12) +- Acceptance-criteria source: **`spec.md` only**, lines 292-296 + +Blocking findings in this artifact: **0**. (The branch total is 1, recorded as F-1 in +`policy-audit.2026-09-08T11-30.md`.) + +## AC Source Resolution + +`issue.md` line 12 records `- Work Mode: full-bug`. Per +`.claude/skills/acceptance-criteria-tracking/SKILL.md`, `full-bug` resolves the acceptance-criteria +source to `spec.md` only. `issue.md` lines 67-71 carry an AC-shaped checkbox list with identical +wording; it is **not** the authority under this work mode and was not audited as such. No +`user-story.md` exists, which is correct for `full-bug`. + +`spec.md` has exactly five criteria under `## Acceptance Criteria` at lines 292-296. + +## Acceptance Criteria Evaluation + +| AC | Criterion (abbreviated) | Verdict | Checkbox state in `spec.md` | +|---|---|---|---| +| AC1 | `TryAddValuesAsync` no longer cancels on a fixed wall-clock window; the test passes deterministically under 24-worker parallel coverage runs | PASS | `[x]` — correct | +| AC2 | `DfDeedle_COM_Tests` no longer mutates process-wide static seams another class can observe; the null snapshot element is guarded with a descriptive failure | PASS | `[x]` — correct | +| AC3 | The `Console.Out` races are removed by eliminating the shared-console dependency | PASS | `[x]` — correct | +| AC4 | A full nine-assembly `/InIsolation` run with `TestCategory!=LiveOutlook` reports zero failures on ten consecutive runs, recorded as evidence | **FAIL** | `[ ]` — correct | +| AC5 | No test is stabilized by a sleep, a retry, or a timing tolerance | PASS | `[x]` — correct | + +No checkbox required a change. Every criterion this reviewer evaluated PASS was already checked; +the one criterion evaluated FAIL was already unchecked. This reviewer made no edit to `spec.md`. + +### AC1 — PASS + +Both halves verified independently against the post-change source rather than against the executor's +report. + +Mechanism: `UtilitiesCS/Extensions/DictionaryExtensions.cs` no longer creates a linked token source +and no longer calls `CancelAfter(500)`. The body is now +`return await Task.Run(() => dictionary.TryAddValues(key, value), token);`, so cancellation is +governed solely by the caller's token. No `TimeProvider` was needed because the window guarded +nothing: a token supplied to `Task.Run` can only cancel work that has not yet started, and the body +is a bounded compare-and-swap loop that performs no I/O. + +Blast radius verified: a repository-wide search over `*.cs` for `TryAddValuesAsync` returns four +hits — the definition at `DictionaryExtensions.cs:169`, one invocation at +`DictionaryExtensions_Tests.cs:244`, one at `:266`, and two test method names. There are **zero +production call sites**, so the deletion cannot alter production behavior. The spec's claim on this +point is confirmed rather than assumed. + +Determinism verified: `DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` — the +sentinel test named in issue #780 — read `Passed` in all ten AC4 runs under 24 class-level workers +with coverage instrumentation (`evidence/regression-testing/p8-t6-ac4-ten-run.md`, exhaustive +per-run check, not a sample). + +Surviving contract locked: the new +`TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` asserts that a +pre-cancelled caller token still produces `TaskCanceledException` and leaves the dictionary value +untouched, so a future edit cannot silently drop cancellation altogether. + +RED-first note: `spec.md` states, and this reviewer accepts, that AC1 admits no deterministic +fail-before test — the original failure is load-dependent and reproducing it would require a sleep, +which AC5 forbids. The RED artifact for AC1 is therefore the contract-shape test rather than a +reproduction, and the spec says so in advance rather than after the fact. + +### AC2 — PASS + +**Clause 1, static-seam mutation.** The two ETL delegate statics are gone. +`DfDeedle.TableEtlInvoker` and `DfDeedle.StoreTableEtlInvoker` are replaced by a single +`private static readonly DefaultTableEtl` plus an optional `etl` parameter on +`GetEmailDataInView(Explorer, ...)` and both `FromDefaultFolder` overloads. The three tests in +`DfDeedle_COM_Tests` that previously swapped and restored those statics now pass `etl:` directly and +their `try`/`finally` blocks are deleted. Mutation of process-wide ETL state by that class is +eliminated, not merely restored. + +`DfDeedle.MessageBoxInvoker` survives as a mutated static in four tests. `spec.md` line 135 settles +this deliberately, and this reviewer verified the exposure argument rather than accepting it: the +only production readers are `DfDeedle.QfcColumns.cs:27,170,194`, and a repository-wide search shows +exactly three test classes reach those paths — `DfDeedle_COM_Tests` itself (whose tests are +serialised by `ExecutionScope.ClassLevel`), `DfDeedleQfcColumnTimeoutTests` and the new +`DfDeedleEtlTimeoutTests`, both `[DoNotParallelize]`. The criterion's qualifier "in a way another +class can observe" is therefore satisfied. The residual is that this holds only while both reader +classes retain their attribute, and nothing enforces that; recorded as C-5 in +`code-review.2026-09-08T11-30.md`, not as an AC shortfall. + +**Clause 2, the null-snapshot guard.** `DfDeedle.GetEmailDataInViewAsync` now tests +`tableSnapshot.data is null` immediately after the `EtlAsync` call and throws +`InvalidOperationException` naming the folder and stating that the table ETL timed out or was +cancelled before returning rows. The guard is placed before the `LogDfTiming` statement that +dereferences `tableSnapshot.Item1` — the statement the CI stack trace reported — and therefore also +before the pre-existing `ValidateRequiredEmailColumns` guard, which inspects the column map only and +would not have caught this. + +RED-first evidence exists for AC2 and is the strongest evidence in the item: +`evidence/regression-testing/p2-t4-ac2-fail-before.md` and +`fail-before-exception.2026-09-08T09-50.md` record the pre-fix `NullReferenceException`; +`p3-t2-ac2-pass-after.md` records the post-fix `InvalidOperationException`. The assertion names the +exception type explicitly and matches the folder name (`WithMessage("*Inbox*")`), so a regression to +`NullReferenceException` fails the test rather than passing a generic throw assertion. + +Sentinel determinism: `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` — the +test named in #803 and #594 — read `Passed` in all ten runs, now driven by an un-advanced +`FakeTimeProvider` so no deadline on its path can fire under host load. + +### AC3 — PASS + +The criterion says "two" races; `spec.md` lines 59-64 records the discrepancy and derives four +capture-and-assert sites twice by two independent search strategies. This reviewer confirmed all +four are converted in the diff: + +| Site | Conversion | `[DoNotParallelize]` | +|---|---|---| +| `StackGeek_Tests.Main_RunsSampleScenarioWithoutThrowing` | split into a null-writer default test plus `Run_WritesScenarioToSuppliedWriter` asserting on a `StringWriter` | removed | +| `PrettyPrint_Tests.DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput` | renamed `...AndWriterOutput`; passes `writer` to both `PrettyPrint` overloads | removed | +| `DASLFilterParserTests.PrintTree_WritesIndentedTreeToConsole` | renamed `...ToSuppliedWriter`; passes `writer` to `PrintTree` | removed | +| `OlTableExtensions_Tests.EnumerateTable_WritesFormattedOutputAndMovesToStart` | renamed `...ToSuppliedWriterAndMovesToStart`; passes `output` to `EnumerateTable` | retained, with a corrected rationale | + +The fifth site, the propagator `NLogTraceWriter_Test`, had its `Console.Out` save at +`TestInitialize` and its restoring `TestCleanup` deleted entirely, together with the now-unused +`originalOut` field. That removes the mechanism by which it could capture a sibling class's writer +and later install it process-wide. + +In every one of the four converted tests, no `Console.SetOut` call and no `Console.Out` read remains. +The shared-console dependency is eliminated, which is what the criterion asks for, rather than +serialised. + +Retention of the fourth attribute is not a shortfall. `spec.md` Mitigation 4 pre-authorises exactly +this conservative fallback for `OlTableExtensions_Tests`, on the ground that removing the attribute +from an 1846-line COM-mock class with ten tests driving a 2000 ms window carries risk unrelated to +the console. The comment was rewritten so it no longer claims a reason that is now false. The class +is still converted to the seam, which is the criterion's substance. + +Determinism verified: all four converted tests read `Passed` in all ten AC4 runs, three of them now +running inside the parallel set rather than the serialized tail. + +### AC4 — FAIL + +The criterion requires zero failures on ten consecutive full nine-assembly `/InIsolation` runs. +Nine runs were clean. Run 7 reported `passed`=7161 against `total`=7162. + +The failing test was +`UtilitiesCS.Test.NewtonsoftHelpers.SDILReader.MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions`, +failing on `Expected bodyCode ... to contain "ldstr"`. + +This reviewer verified the cause independently by reading the source, and separately corroborated it +from the failure output. The full derivation, including why the observed single-entry degradation +and the unresolved `0x70……`-range metadata token uniquely identify a mid-initialisation read of +`ILGlobals.singleByteOpCodes`, is recorded as F-1 in `policy-audit.2026-09-08T11-30.md`. Summary: +`ILGlobals.LoadOpCodes()` reassigns the table to an all-default array at line 123 before filling it +at line 137, with no lock or `Lazy`; two test classes call it and neither carries +`[DoNotParallelize]`. + +Attribution, reached independently and stated in full in section 4.2 of +`code-review.2026-09-08T11-30.md`: the defect is pre-existing and wholly outside this item. Neither +racing class is in the write set; neither gained nor lost a parallelism attribute; the vulnerable +window is byte-identical. The change did move three classes into the parallel set, but instantaneous +concurrency is capped at 24 workers and the eligible pool already exceeds 400 classes, so the pool +was worker-saturated before and after; the three short in-memory classes perturb dispatch order +without raising density. No directional increase in surfacing probability can be established, and +the evidence does not measure the base rate. + +Disposition: **the AC4 shortfall is Non-blocking for merge.** The branch removes three failure modes +and leaves one; blocking it would leave four in `main`. Fixing `ILGlobals` inside this item would +breach the minimal-fix requirement of CLAUDE.md's Bugfix Workflow. What is Blocking is that the +finding has no durable follow-up artifact (F-1). + +Process quality note: the executor did not re-run until green. `p8-t4-ac4-runs.md` states "No re-run +was performed: re-running until green is what AC4 exists to prevent, and doing it here would destroy +the evidence." That is the correct discipline and is worth recording, because the opposite behavior +would itself have been a blocking finding. + +### AC5 — PASS + +The criterion constrains technique, not only outcome. Verified against the diff independently of the +executor's search: + +- Zero `Thread.Sleep`, `Task.Delay`, `CancelAfter`, `SpinWait`, `Stopwatch`, timed `.Wait(n)`, + `new CancellationTokenSource()` or `Returns(120)` occurrences among the added lines. +- Zero `catch` blocks in the two new test files; both use `finally` only, to release gates. +- The three surviving `.Wait` references (`gateA.Wait`, `gateB.Wait`, `gate.Wait`) are + `ManualResetEventSlim.Wait()` **method groups passed as delegate arguments**, invoked inside mock + callbacks so the production code blocks until the test itself releases the gate in `finally`. An + untimed wait cannot mask a timing defect: it places no bound on how long the code under test may + take, and the outcome is decided by the injected clock rather than elapsed wall time. This is the + same pattern the pre-existing `DfDeedleQfcColumnTimeoutTests` already uses. +- The pre-existing tolerance at `OlTableExtensions_Tests.cs:960-963` — `GetRowCount()` returning 120 + with the comment "so the timeout cannot fire under test-host contention" — is **retired**, not + merely absent from added lines. Both tokens count 0 in the final tree; each counted 1 at the base + commit. It is replaced by an un-advanced `FakeTimeProvider`. + +The distinction that matters here is between widening a deadline and controlling the clock. The +change consistently does the latter: green-path tests use a clock that never moves, so the deadline +is unreachable rather than enlarged, and production timing is untouched because a `null` +`timeProvider` resolves to `TimeProvider.System`. + +## Baseline Comparison + +| Dimension | Baseline (`bb1c7d4b`) | Head (`3805ca89`) | Direction | +|---|---|---|---| +| Repository line coverage | 86.0109% | 86.0424% | improved | +| Repository branch coverage | 66.3058% | 66.3978% | improved | +| Uncovered lines, `OlTableExtensions.Etl.cs` | 26 | 12 | improved | +| Uncovered lines, `DfDeedle.cs` | 2 | 0 | improved | +| `EtlAsync` method-span line rate | 0.7000 | 0.9796 | improved | +| Mutable process-wide statics on `DfDeedle` | 3 | 1 | improved | +| `[DoNotParallelize]` classes among the four console victims | 4 | 1 | improved | +| Serialized classes, net across the change | n/a | minus 3, plus 2 | net minus 1 | +| Timing tolerances in the touched tests | 1 | 0 | improved | +| `DateTime.Now` calls on the ETL log path | 1 | 0 | improved | +| Known intermittent failure modes in `UtilitiesCS.Test` | 4 (3 in scope, `ILGlobals` latent) | 1 (`ILGlobals`) | improved | +| Analyzer warnings, full solution | 0 | 0 | held | +| Nullable / warnings-as-errors diagnostics | 0 | 0 | held | +| Files over the 500-line cap among the write set | 3 | 3 (all held or shrank) | held | +| Test total | not recorded at base | 7162 | +9 added tests | + +## Deliverables Against `spec.md` Item List + +`spec.md` enumerates a 20-item write set (8 production files, 11 test files, 1 project file). The +diffstat confirms exactly those 20 source paths changed and no others. No file outside the declared +write set was modified. The three new files listed at items 11, 12 and 14 exist and are wired into +the project via three `` items, which is required because these are legacy non-SDK +`packages.config` projects where an unlisted `.cs` file is silently not compiled. + +Non-goals held: issue #592 is untouched; the 250 ms per-row budget is unchanged; the 2000 ms +`GetTableInViewAsync` window is unchanged; the inert `(int, int)` `TimeoutAfter` overloads remain in +`TimeOutTask.cs`, which is byte-identical at 1011 lines; `EtlAsync`'s tuple contract is unchanged; +the roughly 24 unrestored `DebugTextWriter` aggressors are untouched. Two of these were filed as +`docs/features/potential/` follow-up entries. + +## Acceptance Criteria Status + +``` +### Acceptance Criteria Status +- Source: docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/spec.md +- Total AC items: 5 +- Checked off (delivered): 4 +- Remaining (unchecked): 1 +- Items remaining: AC4: A full nine-assembly /InIsolation run with TestCategory!=LiveOutlook reports zero failures on ten consecutive runs, recorded as evidence. +``` + +Newly checked off by this reviewer: none. All four PASS criteria were already checked, and the one +FAIL criterion was already unchecked. The AC state in `spec.md` is accurate as delivered. + +## Verdict + +**Feature verdict: PASS with one outstanding acceptance criterion and one blocking process finding.** + +4 of 5 acceptance criteria are met with verified evidence. AC4 is not met, for a reason this +reviewer independently attributes to a pre-existing defect outside the write set. The single +blocking item before merge is F-1: file and promote the `ILGlobals` race so the finding survives the +merge. Remediation inputs are recorded in `remediation-inputs.2026-09-08T11-30.md`. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/issue.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/issue.md new file mode 100644 index 000000000..d70847b9e --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/issue.md @@ -0,0 +1,82 @@ +# utilitiescs-test-determinism-780-803-594 (Issue #811) + +- Date captured: 2026-09-07 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/utilitiescs-test-determinism-780-803-594/ (Issue #811) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #811 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/811 +- Last Updated: 2026-09-08 +- Work Mode: full-bug + +## Summary + +Consolidates the `UtilitiesCS.Test` nondeterminism filed as #780, #803, and #594 into one item so the required `mstest-coverage` check stops failing on unrelated pull requests. #803 is a duplicate of the `DfDeedle_COM_Tests` `NullReferenceException` already in #594: `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` fails under parallel load because the class mutates static seams and production code at `DfDeedle.cs:186` dereferences `tableSnapshot.Item1` without a null check. #780 is `DictionaryExtensions.TryAddValuesAsync` cancelling its inner `Task.Run` after a hard-coded 500 ms wall-clock window, which the thread pool exceeds under coverage instrumentation with 24 class workers. #594 also carries two `Console.Out` races between concurrently executing tests. #592 (QuickFiler pump-host 60 s expiry) is deliberately not included: it is a different assembly and needs its own investigation. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 locally; GitHub Actions windows runner in CI +- Runtime: .NET Framework 4.8.1 test host, VSTest 18.9.0, MSTest 4.4.0, assembly-level parallelization with 24 class-scope workers +- Command/flags used: `vstest.console.exe /EnableCodeCoverage /InIsolation /Logger:trx "/TestCaseFilter:TestCategory!=LiveOutlook"`; the repository `mstest-coverage` job +- Data source or fixture: in-memory dictionaries; mocked `Table` and `MAPIFolder` in `DfDeedle_COM_Tests` + +## Steps to Reproduce + +1. Run the full suite under coverage with parallel class workers. Observe, intermittently: `TryAddValuesAsync_UpdatesExistingValue` failing with `TaskCanceledException` after ~20 s (passes in ~2 ms alone); `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` failing with `NullReferenceException` at `DfDeedle.cs:186`; two `Console.Out` races. +2. Re-run the identical head commit with no change and observe all checks passing. Observed on PR #802 at `b2132349` (7107 of 7108 passed, then green on re-run) and during PR #779 verification on 2026-09-04. + +## Expected Behavior + +- Every test in `UtilitiesCS.Test` passes deterministically regardless of class ordering and parallel load. +- `TryAddValuesAsync` does not fail production callers on thread-pool scheduling latency. +- Production code at `DfDeedle.cs:186` does not dereference a possibly-null snapshot element. +- A full nine-assembly `/InIsolation` run reports zero failures on ten consecutive runs. + +## Actual Behavior + +Intermittent failures as above, each blocking a required check on an unrelated pull request and inviting re-run-until-green. + +## Logs / Screenshots + +- [x] Attached minimal logs or screenshot +- Snippet (#780, 2026-09-04): `System.Threading.Tasks.TaskCanceledException: A task was canceled. at UtilitiesCS.DictionaryExtensions.d__10\`2.MoveNext() in UtilitiesCS\Extensions\DictionaryExtensions.cs:line 179`. Local run 4767 tests, 4766 passed, the failing test alone took 21 s. +- Snippet (#803, PR #802 run 1): `System.NullReferenceException at UtilitiesCS.DfDeedle.GetEmailDataInViewAsync, UtilitiesCS/Extensions/DfDeedle.cs line 186`, Total 7108, Passed 7107, Failed 1. + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [x] Medium +- [ ] Low + +Medium: a nondeterministic failure in a required check blocks unrelated pull requests and erodes the signal the gate exists to provide. Production callers of `TryAddValuesAsync` may also receive a spurious cancellation under starvation. + +## Suspected Cause / Notes + +- #780: wall-clock timeout in production code measures scheduling latency, not the operation; the fix is to remove the hard-coded window or inject a `TimeProvider` so tests use `FakeTimeProvider`, per `.claude/rules/csharp.md` time-seam guidance. +- #803 / #594 item 1: static seam mutation in `DfDeedle_COM_Tests` races other classes; the production null dereference at `DfDeedle.cs:186` turns a race into an NRE. Fix both: make the seam instance-scoped or serialize the class with `[DoNotParallelize]` on a documented basis, and guard the snapshot dereference. +- #594 items 2 and 3: eliminate the shared-console dependency rather than serializing with sleeps or retries. +- Superseded issues: #780, #803, #594 (close with a pointer to this issue). + +## Proposed Fix / Validation Ideas + +Acceptance criteria: + +- [ ] AC1: `TryAddValuesAsync` no longer cancels on a fixed wall-clock window, or the window is driven by an injected `TimeProvider`; the test passes deterministically under 24-worker parallel coverage runs. +- [ ] AC2: `DfDeedle_COM_Tests` no longer mutates process-wide static seams in a way another class can observe, and `DfDeedle.cs:186` guards the null snapshot element with a descriptive failure. +- [ ] AC3: The two `Console.Out` races are removed by eliminating the shared-console dependency. +- [ ] AC4: A full nine-assembly `/InIsolation` run with `TestCategory!=LiveOutlook` reports zero failures on ten consecutive runs, recorded as evidence. +- [ ] AC5: No test is stabilized by a sleep, a retry, or a timing tolerance. + +Validation: + +- [ ] Unit coverage areas: `DictionaryExtensions.TryAddValuesAsync` with `FakeTimeProvider`; `DfDeedle.GetEmailDataInViewAsync` null-snapshot path. +- [ ] Integration scenario to retest: ten consecutive full-suite runs locally and one CI run. +- [ ] Manual verification notes: none. + +## Next Step + +- [ ] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/plan.2026-09-07T22-03.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/plan.2026-09-07T22-03.md new file mode 100644 index 000000000..800d8f05d --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/plan.2026-09-07T22-03.md @@ -0,0 +1,592 @@ +# 2026-09-07-utilitiescs-test-determinism-780-803-594 (Plan) + +- **Issue:** #811 +- **Parent (optional):** none +- **Owner:** drmoisan +- **Last Updated:** 2026-09-08T09-06 +- **Status:** Approved for execution. Executor preflight returned PREFLIGHT: ALL CLEAR on round 3, after the round-1 delta (nine defects) and the round-2 delta (four defects) were applied. The MCP plan validator returns ok with no warnings in either channel. Re-anchored at 2026-09-08T09-06 after origin/main was merged into this branch as merge commit bb1c7d4b60f7b782227956f36859314d5c47bb03. Two classes of fact moved and nothing else did: the anchor commit itself, and `UtilitiesCS.Test/UtilitiesCS.Test.csproj`, which gained three `` items from the already-merged items #809 and #812. Those three items shift the project file from 982 to 985 lines and move two of its three cited item positions. Every other write-set citation and line count was re-measured against the merged tree and is unchanged, including all sixteen `.cs` line counts and `TimeOutTask.cs`. +- **Version:** 1.3 +- **Work Mode:** full-bug +- **Base commit:** bb1c7d4b60f7b782227956f36859314d5c47bb03 (the origin/main merge commit on this branch; the pre-merge anchor was 04a54e681bd21e841e124c016df30672ee701b75, which is no longer a valid diff anchor because main advanced 31 commits behind this branch) +- **Branch:** bug/utilitiescs-test-determinism-780-803-594-811 + +**Fail-closed evidence rule:** Every evidence-producing task names its artifact path. If any required +baseline artifact, QA artifact, or coverage-comparison artifact is missing or incomplete, the verdict +is BLOCKED or INCOMPLETE, never PASS. An unchecked task may not be checked off without its artifact. + +**Evidence accounting rule:** Each command-step artifact carries `Timestamp:`, `Command:`, +`EXIT_CODE:`, and `Output Summary:`. Artifacts for gates whose expected exit code is non-zero also +carry `ExpectedExitCode:`. One artifact per non-zero-capable gate. + +**Requirements authority.** spec.md is the sole acceptance-criteria source for this `full-bug` item +and carries exactly five criteria, AC1 through AC5, under `## Acceptance Criteria` at spec.md line +291. user-story.md is absent and stays absent. The research artifact +`research/root-cause.2026-09-07T22-10.md` is authoritative for mechanism; spec.md is authoritative +for scope and acceptance; where they differ, spec.md wins. + +--- + +## Write Set Under Change + +The definitive list is spec.md `#### Files/modules to change:` (items 1 to 20). Repository-relative +paths, with the line count measured in this worktree at the base commit: + +### Production (8), all carry `#nullable enable` + +1. `UtilitiesCS/Extensions/DictionaryExtensions.cs` (282) +2. `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` (474; 26 lines of headroom under the cap) +3. `UtilitiesCS/Extensions/DfDeedle.cs` (314) +4. `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs` (276) +5. `UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs` (122) +6. `UtilitiesCS/HelperClasses/PrettyPrint.cs` (680; PRE-EXISTING cap violation; zero net growth) +7. `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs` (430) +8. `UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs` (199) + +### Tests (11), none carries a whole-file `#nullable enable` + +9. `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs` (296) +10. `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` (869; PRE-EXISTING cap violation; must end strictly below 869) +11. `UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs` (NEW) +12. `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` (NEW) +13. `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` (exactly 500; may not gain one line) +14. `UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs` (NEW; receives the moved helper) +15. `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs` (276) +16. `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs` (408) +17. `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs` (125) +18. `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` (1855; PRE-EXISTING cap violation; must end strictly below 1855) +19. `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs` (119) + +### Project file (1) + +20. `UtilitiesCS.Test/UtilitiesCS.Test.csproj` (985; Compile items only; the 500-line cap does not reach `*.csproj`) + +Total: 20 paths. Feature-folder artifacts under +`docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/` (written `` +below) and the two follow-up entries under `docs/features/potential/` are additionally created or +updated by this plan and are outside the source write set. + +Not touched, and every gate is written so that it cannot require touching them: +UtilitiesCS/Threading/TimeOutTask.cs (1011 lines; the inert `(int, int)` overloads stay), +UtilitiesCS/Extensions/DfDeedle.QfcColumns.cs (its line-96 doc comment names `TableEtlInvoker` and +becomes stale; recorded as a follow-up in P8-T9, not edited), the roughly 24 test classes that install +a `DebugTextWriter` with no restore, any `.github/workflows/` file, any `packages.config`, any +`.runsettings`, `.editorconfig`, and `BannedSymbols.txt`. + +--- + +## Decisions record (settled; not re-opened by the executor) + +- **D1. Production timing is unchanged, byte-for-byte.** `TimeOutTask.cs:824` (`(this Task, int millisecondsTimeout, int repeatAttempts)`) calls `task.TimeoutAfter(millisecondsTimeout)` at line 834, which binds to the `(this Task, int, TimeProvider? timeProvider = null)` overload at line 862. That overload is not `async` and returns the task itself (line 873) or a proxy that a timer later faults; it never throws synchronously, so the `catch (TimeoutException)` at line 836 cannot fire and the retry at line 841 is dead. Replacing `.TimeoutAfter(ms, attempts)` with `.TimeoutAfter(ms, timeProvider)` where `timeProvider` is null is therefore exactly today's behaviour on the system clock. No deadline value changes: 250 ms x rowCount stays at `OlTableExtensions.Etl.cs:81`, 1000 ms stays at `DfDeedle.cs:208`, 3000 ms stays in `DfDeedle.QfcColumns.cs`. +- **D2. The AC2 citation.** The issue and AC2 cite `DfDeedle.cs:186`. Line 186 opens the `LogDfTiming(` call; the dereference `tableSnapshot.Item1.GetLength(0)` is on line 188 inside that call. The guard is inserted between line 185 and line 186, ahead of the whole call. +- **D3. `TryAddValuesAsync` has zero production call sites.** The only invocation in the repository is `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs:244`; the other two hits are the definition (`DictionaryExtensions.cs:169`) and the test method name (line 237). `TryAddValues` (lines 123-136) is a bounded compare-and-swap loop that returns `false` when the key is absent; it cannot hang. The 500 ms window is deleted (AC1 first disjunct), not re-seamed. +- **D4. The RED test needs the seam first.** A test that passes `timeProvider:` to `GetEmailDataInViewAsync` cannot compile until the parameter exists. Phase 1 lands every seam with no behaviour change; Phase 2 adds the regression test, which then fails at assertion time with `NullReferenceException`; Phase 3 adds the guard. This is the declaration-then-red ordering, and it keeps the whole test assembly compiling at every task. +- **D5. Deterministic arming order in the RED test.** On the failing path two timers are armed on the injected clock: first the 3000 ms column-add deadline inside `AddQfcColumnsAsync` (`DfDeedle.QfcColumns.cs:127`), then the 250 ms ETL hop deadline (`OlTableExtensions.Etl.cs:245`). Whether the first timer is armed at all depends on a race between the pool thread running `AddQfcColumns` and the caller reaching `TimeoutAfter`. The test removes the race with two test-owned gates: `Columns.Add("SentOn")`, the first call `AddQfcColumns` makes (`DfDeedle.QfcColumns.cs:40`), blocks on gate A; `Table.GetNextRow()` blocks on gate B. The test awaits `Armed` (timer 1, deterministic because the adder is blocked), re-arms, releases gate A, awaits `Armed` (timer 2, deterministic because the row read is blocked), then advances the clock by 250 ms. Both gates are released in `finally` so the orphaned `Task.Run` bodies complete. `FakeTimeProvider` arms timers relative to its current time, so advancing before timer 2 exists would hang the test; this ordering is what prevents that. +- **D6. AC1 has no deterministic RED.** The original failure is load-dependent and cannot be reproduced without a sleep, which AC5 forbids. AC1's fail-before evidence is a `fail-before-exception` dossier (P4-T3) whose alternative proof is the structural count gate on `CancelAfter(` (1 before, 0 after), plus the contract-lock test that a pre-cancelled token still yields `TaskCanceledException`. The spec Test Strategy's "token cancelled after the work is observed to start" test is not authored: `TryAddValues` offers no observation point without a custom operator type, and the test would pass identically before and after the fix, so it has no discriminating power. This is a recorded deviation from the spec's Test Strategy prose; it changes no acceptance criterion. +- **D7. `DfDeedle` static seams.** `TableEtlInvoker` (`DfDeedle.cs:69-72`) and `StoreTableEtlInvoker` (`DfDeedle.cs:81-84`) are deleted and replaced by an optional trailing delegate parameter on `GetEmailDataInView(Explorer)` and on both `FromDefaultFolder` overloads. One `private static readonly` default delegate `DefaultTableEtl` replaces both (they are the same lambda `t => ((Outlook.Table)t).ETL()`). It is written in the same one-line-initializer shape as the statics it replaces so the per-file coverage accounting in P7-T6 sees the same line shape. `MessageBoxInvoker` (`DfDeedle.cs:54-60`) stays static per spec.md; its restored-in-`finally`, reader-free status is documented in the test-class header. +- **D8. `[DoNotParallelize]` disposition.** Removed from `StackGeek_Tests` (line 15), `PrettyPrint_Tests` (line 19) and `DASLFilterParserTests` (line 14), whose comments name the console redirect as the sole reason. Retained on `OlTableExtensions_Tests` (line 20) under spec.md Mitigation 4, with the comment at lines 17-19 rewritten so it no longer claims the console: the class is 1855 lines of COM-mock tests, ten of its tests drive `GetTableInViewAsync`/`RunWithTimeout` (a 2000 ms wall-clock window), and it has never been soaked under class-level parallelism. Removal after a soak is a follow-up (P8-T9). +- **D9. `OlTableExtensions.Etl.cs` headroom.** The change adds one signature line, two comment lines and removes one local, for a net of about +3 (474 to about 477). The dead `EtlAsyncOld` (lines 132-169) is NOT deleted: doing so would also delete its test at `OlTableExtensions_Tests.cs:986-1017` and widen the diff for no acceptance gain. Consequence: `EtlAsyncOld` retains `var attempts = 3;` (line 148), `.TimeoutAfter(milliseconds, attempts)` (line 158) and `DateTime.Now` (line 163), so every gate over those tokens in this file is an exact-count gate, never a zero-hit gate. +- **D10. `PrettyPrint.cs` zero-growth budget.** Adding `using System.IO;` costs +1 and each seamed overload wraps to two lines under CSharpier's 100-column width (+2). Three removals pay for it: line 6 `using System.Threading.Tasks;` (the token `Task` does not occur anywhere in the file), line 12 `using System.Text;` (duplicates line 5), line 18 `using Svg;` (the token `Svg` occurs only on line 18). If, and only if, the P5-T10 build reports a CS0103 or CS0246 in `PrettyPrint.cs`, line 18 is restored and line 15 `using System.Windows.Input;` (no `System.Windows.Input` type name occurs in the file; the two `Key` hits at lines 174 and 535 are `kvp.Key`/`x.Key` property reads) is removed instead, and the artifact records the substitution. The gate is the outcome: the file's line count is at most 680 after every task. +- **D11. AC4 is local evidence, not a CI gate.** `.github/workflows/_mstest-coverage.yml:86-92` discovers assemblies with a recursive `*.Test.dll` search (not a hard-coded list) and the job carries `timeout-minutes: 30`; ten consecutive full runs do not fit. The local gate enumerates the nine assemblies explicitly (no recursive discovery, so the dot-claude worktree problem never arises), extends the filter with the shell-icon exclusion that the #798 baseline recorded as `SHELL_ICON_EXCLUSION: REQUIRED` on this host (one of 23 shell-icon tests fails per run with an invalid Win32 icon handle, independent of this fix; CI runs them unfiltered), omits `/Settings:` so parallelism comes from `[assembly: Parallelize(Workers = 0, Scope = ClassLevel)]` at `UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21` exactly as in CI, and is split into five two-run tasks so each fits inside one 600000 ms tool invocation. P0-T12 establishes once, before any source change, whether the CI-verbatim shape is locally viable. +- **D12. AC5 has no automated enforcement.** `BannedSymbols.txt` (7 entries) bans `Thread.Sleep` and `Task.Delay` but not `CancelAfter`, `TimeoutAfter` or `WaitOne`, and `.editorconfig:548` holds RS0030 at `suggestion`, so no toolchain step fails on a new sleep. P7-T10 is an explicit search over the anchored diff's added lines. The pre-existing tolerance at `OlTableExtensions_Tests.cs:960-963` (`Returns(120)` with the comment "so the timeout cannot fire under test-host contention") is retired in P6-T1. +- **D13. New test files must be registered.** `UtilitiesCS.Test.csproj` is a legacy non-SDK project with explicit `` items (in the merged base tree the `DfDeedle` family sits at lines 189-192, `TestHelpers\` at 74-77, and `OutlookObjects\Table\OlTableExtensions_Tests.cs` at 541). An unregistered test file still builds and is silently absent from the assembly. Every task that creates a test file is immediately followed by the task that registers it, and P8-T1 carries a non-vacuity control naming every new test. +- **D14. The reflection call at `OlTableExtensions_Tests.cs:1124-1137`** binds `EtlByRowAsync` by an explicit four-type array (`Table`, `Dictionary>`, `Dictionary`, `CancellationToken`), selecting the four-parameter overload at `OlTableExtensions.Etl.cs:171`. P1-T1 changes only the seven-parameter overload at line 229. P6-T2 re-checks the call after all edits, because a reflection binding is invisible to the compiler. +- **D15. Coverage measurement.** Baseline and final full-suite runs use the repository wrapper's own argument shape (`scripts/vscode/Invoke-MSTestWithCoverage.ps1:70-76`): `dotnet-coverage collect ... -- /Settings:scripts\vscode\TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:...`, issued directly because the wrapper hard-codes its filter at line 76 and cannot take the shell-icon exclusion, and because its discovery at line 301 rejects every assembly under a dot-claude path when run from an agent worktree. Raw Cobertura documents stay under the gitignored `coverage/` directory (`.gitignore:144`) and are NOT committed; per-file figures are recorded in the artifacts. The repository-wide root `line-rate` is not reproducible run-to-run on this pipeline (the merged denominator moves), so it is reported under a two-branch comparability rule and the blocking coverage gates are per-file and per-changed-line. +- **D16. The base anchor was moved by the origin/main merge.** After this plan cleared preflight, origin/main advanced 31 commits ahead of the original anchor `04a54e681bd21e841e124c016df30672ee701b75` and was merged into this branch as `bb1c7d4b60f7b782227956f36859314d5c47bb03`. The original anchor is no longer a usable diff anchor: an anchored `git diff --name-only` over the merged tree would enumerate main's own source files as well as this item's, so the P7-T9, P7-T12 and P8-T12 write-set gates would be unsatisfiable against it. `$Base` is therefore the merge commit. The merge changed exactly one write-set file, `UtilitiesCS.Test/UtilitiesCS.Test.csproj`, which gained three `` items (`TestHelpers\UiThreadStateScope.cs`, `OutlookObjects\Folder\FolderPredictorArchiveRootDegradationTests.cs`, `Threading\UiThreadInitContract_Tests.cs`) from the already-merged items #809 and #812. Every project-file line count in this plan was raised by three (985 in the merged base tree, 986 after P1-T7, 987 after P2-T2, 988 after P6-T4) and the item citations moved to 74-77, 189-192 and 541. Item #798, which this plan's `DfDeedle` citations depend on, merged as `4a29d7e7` BEFORE the original anchor, so it was already present when the plan was authored and its files are unaffected. All sixteen write-set `.cs` line counts and `TimeOutTask.cs` were re-measured against the merged tree and are identical to the values P0-T6 asserts, so P0-T6 is unchanged; it carries no project-file line count. If origin/main advances again mid-execution, the executor stops at the phase boundary and returns to the orchestrator for a fresh reconciliation rather than re-anchoring on its own. + +--- + +## Plan-wide conventions + +### C1. Shell preamble (every fenced PowerShell block) + +Every command block is executed as one `pwsh -NoProfile -File coverage/plan811-helper.ps1` invocation. +The helper path is fixed, gitignored (`coverage/*`), rewritten in place for every task, registered +in no project file, asserted by no acceptance condition, and is not a deliverable. No shell variable +survives between tasks; every block begins with this preamble verbatim: + +```powershell + $ErrorActionPreference = 'Stop' + $Root = (git rev-parse --show-toplevel) + Set-Location -LiteralPath $Root + if ((git rev-parse --abbrev-ref HEAD) -ne 'bug/utilitiescs-test-determinism-780-803-594-811') { throw 'wrong worktree' } + $LASTEXITCODE = 0 + $Base = 'bb1c7d4b60f7b782227956f36859314d5c47bb03' + $Feature = 'docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811' + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + $vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 + $msbuild = & $vswhere -latest -products * -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1 + $dotnet = Join-Path $Root '.dotnet-sdk\dotnet.exe' + $Assemblies = @( + 'QuickFiler.Test\bin\Debug\QuickFiler.Test.dll', 'SVGControl.Test\bin\Debug\SVGControl.Test.dll', + 'Tags.Test\bin\Debug\Tags.Test.dll', 'TaskMaster.Test\bin\Debug\TaskMaster.Test.dll', + 'TaskTree.Test\bin\Debug\TaskTree.Test.dll', 'TaskVisualization.Test\bin\Debug\TaskVisualization.Test.dll', + 'ToDoModel.Test\bin\Debug\ToDoModel.Test.dll', 'UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll', + 'VBFunctions.Test\bin\Debug\VBFunctions.Test.dll') + $FullFilter = 'TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser' + $Blame = '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' +``` + +Lines inside fenced blocks are indented four spaces so that no line begins at column 0 with `#`. +Backslash-bearing literals are written with single backslashes inside single-quoted PowerShell +strings; no doubled backslash appears anywhere in this document. + +### C2. Toolchain commands (verbatim from CLAUDE.md; order is binding; restart from step 1 on any failure or rewrite) + +1. `dotnet tool run csharpier format .` (invoked as `& $dotnet tool run csharpier format .`), verified with `dotnet tool run csharpier check .` +2. msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +3. msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +4. `vstest.console.exe` over the nine assemblies with coverage (D15 form for the coverage steps; CI-verbatim `/EnableCodeCoverage` form for AC4) + +Facts that make a gate vacuous or unsatisfiable if ignored: + +- `/t:Rebuild` is mandatory for the two solution gates. A warm `/t:Build` returns exit 0 with `CoreCompile` skipped and runs no analyzers. +- `/p:Nullable=enable` is never added. CI omits it; adding it conscripts every un-annotated file. +- `"/p:Platform=Any CPU"` is a solution-level alias. Project-file builds (used only to produce a test assembly for a scoped run, never as a gate) use `/p:Platform=AnyCPU` without the space. +- A successful msbuild prints the substring `error` in switch names and summary text. Build gates assert the exit code plus the verbatim summary line `0 Error(s)` read from a normal-verbosity file log written to `coverage/msbuild-.log`, never the absence of the substring. +- `csharpier format` exits 0 whether or not it rewrote anything, and `Formatted N files in Mms.` is a processed-file count, not a rewrite count. Every format task records SHA-256 hashes of the 19 write-set `.cs` files before and after, defines rewritten-count as the number of hash differences, and is paired with `csharpier check .`, whose success line begins with the literal `Checked ` and ends with `ms.` (observed on this host: `Checked 1587 files in 7826ms.`). +- Every `git diff` is anchored to `$Base`. `$Base` is the merge commit that brought origin/main onto this branch, and it is also the value of `HEAD` at the start of execution, because no commit occurs before P7-T12. P3-T3's numstat over spec.md stays written against `HEAD`, which therefore resolves to the same commit as `$Base` when that task runs; spec.md is tracked at both, so the numstat is well-defined against either. Every name-listing diff is paired with `git add --intent-to-add` for the three new test files and a `git status --porcelain --untracked-files=all` span in the same task. +- The dot-claude agent-memory tree is tracked and may be dirty from preparation agents. Every diff, status and name-listing gate is scoped with the pathspec exclusion written in plain prose as ":(exclude).claude". +- A bare `/Logger:trx` names the file after the account and machine. Every vstest invocation passes `"/Logger:trx;LogFileName=.trx"` (double-quoted; an unquoted semicolon terminates the argument) and `/ResultsDirectory:coverage/trx/` (gitignored, one directory per task). TRX and `.coverage` files are never committed: a TRX carries the account token in `runUser=` and the machine name in every `computerName=`. +- No artifact, plan line, or commit message contains an absolute host path, the account name, or the machine name. Tokens are derived at run time (`$acct = [regex]::Escape((Split-Path -Leaf $env:USERPROFILE))`, `$mach = [regex]::Escape($env:COMPUTERNAME)`) and never written out. +- Every token count in this plan is `@(Select-String -LiteralPath -CaseSensitive -SimpleMatch '').Count` unless the clause says "with regex", in which case `-Pattern` replaces `-SimpleMatch` and `-CaseSensitive` is still supplied. `Select-String` is case-insensitive by default, so the switch is load-bearing: without it `TimeProvider` also matches `timeProvider`. +- A test-scoped vstest run against UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll alone cannot reach the repository's only `[TestCategory("LiveOutlook")]` test (it lives in TaskMaster.Test), so scoped filters use `FullyQualifiedName~` joined with `|`; every nine-assembly run carries `$FullFilter`. + +### C3. Reusable procedures (copied verbatim into the helper when a task names them) + +**PROC-TRX** (read the counters and named results out of a TRX): + +```powershell + function Read-TrxCounters([string]$Path) { + [xml]$trx = Get-Content -LiteralPath $Path -Raw + $c = $trx.TestRun.ResultSummary.Counters + [pscustomobject]@{ total=[int]$c.total; executed=[int]$c.executed; passed=[int]$c.passed; failed=[int]$c.failed; error=[int]$c.error; timeout=[int]$c.timeout; aborted=[int]$c.aborted; notExecuted=[int]$c.notExecuted } + } + function Read-TrxOutcome([string]$Path, [string]$ClassName, [string]$MethodName) { + [xml]$trx = Get-Content -LiteralPath $Path -Raw + $ns = @{ t = 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010' } + $ut = Select-Xml -Xml $trx -Namespace $ns -XPath "//t:UnitTest[starts-with(t:TestMethod/@className,'$ClassName') and t:TestMethod/@name='$MethodName']" + if (-not $ut) { return 'ABSENT' } + $id = $ut.Node.GetAttribute('id') + $r = Select-Xml -Xml $trx -Namespace $ns -XPath "//t:UnitTestResult[@testId='$id']" + if (-not $r) { return 'NO-RESULT' } + return $r.Node.GetAttribute('outcome') + } + function Read-TrxMessage([string]$Path, [string]$ClassName, [string]$MethodName) { + [xml]$trx = Get-Content -LiteralPath $Path -Raw + $ns = @{ t = 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010' } + $ut = Select-Xml -Xml $trx -Namespace $ns -XPath "//t:UnitTest[starts-with(t:TestMethod/@className,'$ClassName') and t:TestMethod/@name='$MethodName']" + if (-not $ut) { return 'ABSENT' } + $id = $ut.Node.GetAttribute('id') + $m = Select-Xml -Xml $trx -Namespace $ns -XPath "//t:UnitTestResult[@testId='$id']/t:Output/t:ErrorInfo/t:Message" + if (-not $m) { return 'NO-MESSAGE' } + return $m.Node.InnerText + } +``` + +**PROC-COV** (per-file line map from a raw Cobertura document; both node axes merged by line number keeping the maximum `hits`, which is the repository helper's own dedup rule): + +```powershell + function Get-FileLineMap([xml]$Doc, [string]$Suffix) { + $map = @{} + foreach ($cls in $Doc.SelectNodes('//class')) { + $fn = $cls.GetAttribute('filename') + if (-not $fn.EndsWith($Suffix)) { continue } + $nodes = @($cls.SelectNodes('./lines/line')) + @($cls.SelectNodes('./methods/method/lines/line')) + foreach ($ln in $nodes) { + $n = [int]$ln.GetAttribute('number'); $h = [int]$ln.GetAttribute('hits') + if (-not $map.ContainsKey($n) -or $map[$n] -lt $h) { $map[$n] = $h } + } + } + return $map + } + function Get-FileSummary([xml]$Doc, [string]$Suffix) { + $m = Get-FileLineMap $Doc $Suffix + $valid = $m.Count; $covered = @($m.Values | Where-Object { $_ -gt 0 }).Count + "suffix=$Suffix valid=$valid covered=$covered uncovered=$($valid - $covered)" + } + function Get-MethodLineRate([xml]$Doc, [string]$Suffix, [string]$Method) { + foreach ($cls in $Doc.SelectNodes('//class')) { + if (-not $cls.GetAttribute('filename').EndsWith($Suffix)) { continue } + foreach ($mth in $cls.SelectNodes('./methods/method')) { + if ($mth.GetAttribute('name') -eq $Method) { return [double]$mth.GetAttribute('line-rate') } + } + } + return 'ABSENT' + } +``` + +The eight production suffixes, written with single backslashes exactly as the collector emits them: +`UtilitiesCS\Extensions\DictionaryExtensions.cs`, `UtilitiesCS\OutlookObjects\Table\OlTableExtensions.Etl.cs`, +`UtilitiesCS\Extensions\DfDeedle.cs`, `UtilitiesCS\Extensions\DfDeedle.FrameUtilities.cs`, +`UtilitiesCS\OutlookObjects\Filter DASL\DASLFilterParser.cs`, `UtilitiesCS\HelperClasses\PrettyPrint.cs`, +`UtilitiesCS\OutlookObjects\Table\OlTableExtensions.TableAccess.cs`, `UtilitiesCS\ReusableTypeClasses\Other\StackGeek.cs`. + +**PROC-CHANGED** (post-image added line numbers of one file from the anchored diff): + +```powershell + function Get-AddedLines([string]$Base, [string]$Path) { + $out = @() + foreach ($l in (git diff --unified=0 $Base -- $Path)) { + if ($l -match '^@@ -\d+(,\d+)? \+(\d+)(,(\d+))? @@') { + $start = [int]$matches[2]; $len = if ($matches[4]) { [int]$matches[4] } else { 1 } + if ($len -gt 0) { $out += ($start..($start + $len - 1)) } + } + } + return $out + } +``` + +**PROC-HASH** (SHA-256 of the 19 write-set `.cs` files): `Get-FileHash -Algorithm SHA256` over +items 1 to 19 of the Write Set, printed as ` ` lines; the three new files are skipped +while absent. + +### C4. Literals this plan creates (quoted here so a search for them is a real assertion) + +`TimeoutAfter(milliseconds, timeProvider)`, `TimeoutAfter(timeout, timeProvider)`, +`TimeoutAfter(1000, timeProvider)`, `timed out with a timeout of`, `TimeProvider? timeProvider = null`, +`DefaultTableEtl`, `was not produced`, `TextWriter? writer = null`, `Run(Console.Out)`, +`public static void Run(`, `not been soaked under class-level parallelism`, +`class ArmingBarrierTimeProvider`, `Extensions\DfDeedleEtlTimeoutTests.cs`, +`OutlookObjects\Table\OlTableExtensionsEtlClockTests.cs`, `TestHelpers\ArmingBarrierTimeProvider.cs`, +`using UtilitiesCS.Test.TestHelpers;`, `using Microsoft.Extensions.Time.Testing;`, +`MessageBoxInvoker is the one remaining static seam`. + +New test methods (fully qualified; the nine names P7-T5 and P8-T1 assert, seven new plus two retained sentinels): + +- `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` (new) +- `UtilitiesCS.Test.Extensions.DfDeedleEtlTimeoutTests.GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder` (new) +- `UtilitiesCS.Test.Extensions.DfDeedleEtlTimeoutTests.GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame` (new) +- `UtilitiesCS.Test.OutlookObjects.Table.OlTableExtensionsEtlClockTests.EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource` (new) +- `UtilitiesCS.Test.OutlookObjects.Table.OlTableExtensionsEtlClockTests.EtlAsync_ClockNeverAdvances_ReturnsTransformedRows` (new) +- `UtilitiesCS.Test.ReusableTypeClasses.StackGeek_Tests.Run_WritesScenarioToSuppliedWriter` (new) +- `UtilitiesCS.Test.HelperClasses.PrettyPrint_Tests.PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing` (new) +- `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` (retained, the #780 test) +- `UtilitiesCS.Test.Extensions.DfDeedle_COM_Tests.GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` (retained, the #803 test) + +Renamed test methods (behaviour now targets a supplied writer): `PrintTree_WritesIndentedTreeToSuppliedWriter` +(was `PrintTree_WritesIndentedTreeToConsole`), `DataFramePrettyHelpers_RenderRowsMarkdownAndWriterOutput` +(was `DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput`), +`EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart` +(was `EnumerateTable_WritesFormattedOutputAndMovesToStart`). `Main_RunsSampleScenarioWithoutThrowing` keeps its name. + +--- + +### Phase 0 — Policy reads, toolchain bootstrap, and baselines + +- [x] [P0-T1] Read the policy files in the `policy-compliance-order` sequence and record the read in `/evidence/baseline/phase0-instructions-read.md`: CLAUDE.md, then `.claude/rules/general-code-change.md`, `.claude/rules/general-unit-test.md`, `.claude/rules/csharp.md`, `.claude/rules/tonality.md`, `.claude/rules/plan-acceptance-gates.md`, then `/issue.md`, `/spec.md`, `/research/root-cause.2026-09-07T22-10.md`. Acceptance: the artifact carries `Timestamp:`, `Policy Order:` listing the nine paths in that order, the line `Work Mode: full-bug` read from issue.md line 12, the line `AC Source: spec.md lines 292-296 (AC1..AC5)`, and `user-story.md: ABSENT (expected)` after `Test-Path` on `/user-story.md` returns `False`. + +- [x] [P0-T2] Record worktree identity and the anchor commit in `/evidence/baseline/p0-t2-worktree-identity.md`: run the C1 preamble; record `git rev-parse HEAD`, `git rev-parse --abbrev-ref HEAD`, `git cat-file -t bb1c7d4b60f7b782227956f36859314d5c47bb03`, `git merge-base HEAD bb1c7d4b60f7b782227956f36859314d5c47bb03`, and `git status --porcelain --untracked-files=all -- . ":(exclude).claude"`. Acceptance: the branch name equals `bug/utilitiescs-test-determinism-780-803-594-811`; `cat-file -t` prints `commit`; the merge-base output equals `bb1c7d4b60f7b782227956f36859314d5c47bb03` (so the anchor is an ancestor of HEAD; it is HEAD itself until P7-T12 commits); the scoped porcelain lists only paths under `/`; the artifact carries `BASE-SHA: bb1c7d4b60f7b782227956f36859314d5c47bb03` on its own line and no host path. Also record `git rev-parse origin/main` and `git merge-base --is-ancestor origin/main HEAD` (exit 0), which together assert that the merged main is still contained in this branch; if that check fails, main advanced again and the orchestrator re-runs the reconciliation before any source edit. + +- [x] [P0-T3] Bootstrap the repo-local SDK and restore the tool manifest, recording `/evidence/baseline/p0-t3-toolchain-bootstrap.md`: if `Test-Path .dotnet-sdk\dotnet.exe` is `False`, run `pwsh -NoProfile -File scripts/vscode/Install-RepoDotNetSdk.ps1` (global.json pins 8.0.205 under `.dotnet-sdk`; a fresh agent worktree lacks it); then run `& $dotnet tool restore` at the worktree root. Acceptance: `& $dotnet --version` prints `8.0.205`; the tool-restore output contains the line `Tool 'csharpier' (version '1.2.6') was restored. Available commands: csharpier`; both commands exit 0; the artifact records which branch (install or already-present) applied. + +- [x] [P0-T4] Restore NuGet packages for the packages.config projects, recording `/evidence/baseline/p0-t4-package-restore.md`: run `pwsh -NoProfile -File scripts/vscode/Invoke-Restore.ps1` (msbuild `/t:Restore /p:RestorePackagesConfig=true`; parameters `-SolutionPath`, `-Configuration`, `-Platform` default to TaskMaster.sln, Debug, Any CPU). Acceptance: exit 0; `Test-Path packages\Microsoft.Bcl.TimeProvider.10.0.11\lib` and `Test-Path packages\Microsoft.Extensions.TimeProvider.Testing.10.9.0\lib` both print `True` (the seam packages spec.md relies on; no package is added by this plan); the artifact records the count of directories directly under `packages\`. + +- [x] [P0-T5] Resolve the three external tools and record `/evidence/baseline/p0-t5-tool-resolution.md`: `$vstest`, `$msbuild` (both from the C1 preamble) and `Get-Command dotnet-coverage`. Acceptance: `$vstest` and `$msbuild` are non-empty and `Test-Path` on each prints `True`; `dotnet-coverage --version` exits 0 and prints a version string beginning with a digit; if `Get-Command dotnet-coverage` fails, run `dotnet tool install --global dotnet-coverage` and re-check, recording that the install branch applied. Redact every resolved path to `` or `` in the artifact. + +- [x] [P0-T6] Verify this plan's citations against the tree and record `/evidence/baseline/p0-t6-citation-baseline.md`, using `(Get-Content -LiteralPath ).Count` for line counts and `@(Select-String -LiteralPath -CaseSensitive -SimpleMatch '').Count` for token counts (case-sensitive, per C2). Acceptance: every value below matches exactly; on any mismatch the artifact records `CITATION-MISMATCH: ` and execution stops with the plan returned for revision (this is the only halt this plan permits, and it precedes every source edit). + - Line counts: `UtilitiesCS/Extensions/DictionaryExtensions.cs`=282; `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs`=474; `UtilitiesCS/Extensions/DfDeedle.cs`=314; `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs`=276; `UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs`=122; `UtilitiesCS/HelperClasses/PrettyPrint.cs`=680; `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs`=430; `UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs`=199; `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs`=296; `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs`=869; `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs`=500; `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs`=276; `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs`=408; `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs`=125; `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs`=1855; `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs`=119; `UtilitiesCS/Threading/TimeOutTask.cs`=1011; the three new files ABSENT. + - Token counts: in `DictionaryExtensions.cs`: `CancelAfter(`=1, `CreateLinkedTokenSource`=1. In `OlTableExtensions.Etl.cs`: `var attempts = 3;`=2, `TimeoutAfter(milliseconds, attempts)`=2, `TimeoutAfter(timeout, attempts)`=2, `timed out {attempts} times`=2, `DateTime.Now`=2, `TimeProvider`=0. In `DfDeedle.cs`: `TableEtlInvoker`=3, `TimeoutAfter(1000, 2)`=2, `TimeProvider`=0, `was not produced`=0. In `DfDeedle.FrameUtilities.cs`: `StoreTableEtlInvoker`=1. In `DASLFilterParser.cs`: `Console.WriteLine`=1, `TextWriter`=0. In `PrettyPrint.cs`: `Console.WriteLine`=2, `TextWriter`=0. In `OlTableExtensions.TableAccess.cs`: `Console.WriteLine`=5, `TextWriter`=0. In `StackGeek.cs`: `Console.WriteLine`=7, `public static void Run(`=0. In `DfDeedle_COM_Tests.cs`: `TableEtlInvoker`=14, `FakeTimeProvider`=0. In `DfDeedleQfcColumnTimeoutTests.cs`: `class ArmingBarrierTimeProvider`=1. In `StackGeek_Tests.cs`: `Console.SetOut`=3, `DoNotParallelize`=1. In `PrettyPrint_Tests.cs`: `Console.SetOut`=3, `DoNotParallelize`=2 (the line-14 comment mention and the line-19 attribute). In `DASLFilterParserTests.cs`: `Console.SetOut`=3, `DoNotParallelize`=1. In `OlTableExtensions_Tests.cs`: `Console.SetOut`=2, `DoNotParallelize`=1, `Returns(120)`=1, `cannot fire under test-host`=1, `redirects Console.Out`=1, `FakeTimeProvider`=0. In `NLogTraceWriter_Test.cs`: `Console.SetOut`=2, `originalOut`=3, `TestCleanup`=2. In `UtilitiesCS.Test/UtilitiesCS.Test.csproj`: `Extensions\DfDeedleEtlTimeoutTests.cs`=0, `OutlookObjects\Table\OlTableExtensionsEtlClockTests.cs`=0, `TestHelpers\ArmingBarrierTimeProvider.cs`=0. + +- [x] [P0-T7] Baseline toolchain step 1 in `/evidence/baseline/p0-t7-csharpier-check.md`: run `& $dotnet tool run csharpier check .` at the worktree root. Acceptance: the artifact carries `Timestamp:`, `Command: dotnet tool run csharpier check .`, `EXIT_CODE: `, `Output Summary:` quoting the summary line verbatim (it begins with `Checked ` and ends with `ms.`), and a `PRE-EXISTING DRIFT:` line listing every path the tool reported as unformatted, or `PRE-EXISTING DRIFT: none` when the exit code is 0. Expected on this host: exit 0 and `none` (observed one day earlier by #798). + +- [x] [P0-T8] Baseline toolchain step 2 in `/evidence/baseline/p0-t8-msbuild-analyzers.md`: run `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p0-t8.log;Verbosity=normal"`. Acceptance: `EXIT_CODE: 0`; the file log contains the line ` 0 Error(s)` and the artifact quotes the `Warning(s)` and `Error(s)` lines verbatim and records `BASELINE_ANALYZER_WARNINGS: `; `Test-Path UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll` prints `True` afterwards. + +- [x] [P0-T9] Baseline toolchain step 3 in `/evidence/baseline/p0-t9-msbuild-nullable.md`: run `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p0-t9.log;Verbosity=normal"`. Acceptance: `EXIT_CODE: 0`; the file log contains ` 0 Error(s)`; the artifact states in one sentence that `/p:Nullable=enable` was not supplied and why (CLAUDE.md C#1.3). + +- [x] [P0-T10] Baseline toolchain step 4, full nine-assembly coverage run, in `/evidence/baseline/p0-t10-vstest-coverage.md` (PROC-TRX): create `coverage/trx/p0-t10`, then run `dotnet-coverage collect --output coverage/p0-baseline.cobertura.xml --output-format cobertura --settings coverage.config -- $vstest $Assemblies /Settings:scripts\vscode\TaskMaster.cli.runsettings /InIsolation "/Logger:trx;LogFileName=p0-t10.trx" /ResultsDirectory:coverage/trx/p0-t10 $Blame "/TestCaseFilter:$FullFilter"`. Acceptance: `EXIT_CODE: ` recorded (0 when no test failed; 1 when `PREEXISTING_FAILURE_SET:` is non-empty; the baseline is a capture and is not remediated); `coverage/trx/p0-t10/p0-t10.trx` exists and its counters are recorded as `total= executed= passed= failed= error= timeout= aborted= notExecuted=`; `failed` plus `error` plus `aborted` plus `timeout` equals 0 OR the artifact lists every failing test name under `PREEXISTING_FAILURE_SET:` (a failure of `TryAddValuesAsync_UpdatesExistingValue` or `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` here is the defect under repair and is recorded, not remediated); `coverage/p0-baseline.cobertura.xml` exists and the artifact records its root `line-rate`, `lines-covered`, `lines-valid`, `branch-rate`, `branches-covered`, `branches-valid` as numbers (`line-rate` is a fraction, not a percentage). This is the mandatory numeric coverage baseline. The run was measured at 54 s on this host for the wrapper form; invoke with the tool's 600000 ms timeout. + +- [x] [P0-T11] Baseline per-file coverage for the eight production files and the `Main` method in `/evidence/baseline/p0-t11-per-file-coverage.md` (PROC-COV over `coverage/p0-baseline.cobertura.xml`): for each of the eight suffixes record the `Get-FileSummary` line (`valid=`, `covered=`, `uncovered=`); record `Get-MethodLineRate` for suffix `UtilitiesCS\ReusableTypeClasses\Other\StackGeek.cs` method `Main`; for `EtlAsync`, which is `async` and emits no `` element in this pipeline's Cobertura output, record `ETLASYNC_SPAN: 66-130` (declaration line through closing brace, read from the baseline file) and `ETLASYNC_SPAN_RATE: /` computed over the `Get-FileLineMap` entries for suffix `UtilitiesCS\OutlookObjects\Table\OlTableExtensions.Etl.cs` whose line number lies in that span. Acceptance: all eight suffixes match at least one `class` element (`valid` greater than 0); `Get-MethodLineRate` for `Main` is a number, not `ABSENT`; the `EtlAsync` span `valid` is greater than 0; the artifact records `BASELINE_UNCOVERED: =` for each of the eight, which P7-T6 compares against. + +- [x] [P0-T12] Probe the CI-verbatim AC4 command shape once against the pre-change tree and record `/evidence/baseline/p0-t12-ci-shape-probe.md` (PROC-TRX): create `coverage/trx/p0-t12`, then run `& $vstest $Assemblies /EnableCodeCoverage /InIsolation "/Logger:trx;LogFileName=p0-t12.trx" /ResultsDirectory:coverage/trx/p0-t12 $Blame "/TestCaseFilter:$FullFilter"` (no `/Settings:`; parallelism from the assembly attribute, as in CI). Acceptance: `coverage/trx/p0-t12/p0-t12.trx` exists; counters recorded; wall-clock recorded as `PROBE_SECONDS: `; the artifact records exactly one of `AC4_COMMAND_SHAPE: CI-VERBATIM` (every failing test, if any, is one of `TryAddValuesAsync_UpdatesExistingValue`, `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform`, `Main_RunsSampleScenarioWithoutThrowing`, `DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput`, `PrintTree_WritesIndentedTreeToConsole`, `EnumerateTable_WritesFormattedOutputAndMovesToStart`, i.e. the three failure modes this item repairs) or `AC4_COMMAND_SHAPE: RUNSETTINGS-FALLBACK` (any other failure; the artifact lists the names, and every P8 run then adds `/Settings:TaskMaster.runsettings`, which carries the repo-root coverage module exclusions and class-level parallelism for every assembly, a heavier load than CI). `.coverage` files under `coverage/trx/p0-t12` are deleted after the counters are read. + +### Phase 1 — Clock and delegate seams with no behaviour change + +- [x] [P1-T1] Edit `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs`: (a) add a trailing parameter `TimeProvider? timeProvider = null` to `EtlAsync` (signature lines 66-73) preceded by a two-line `//` comment stating that null resolves to the system clock and tests inject `FakeTimeProvider`; (b) delete `var attempts = 3;` at line 82; (c) in the `EtlByRowAsync` call at lines 98-106 replace the `attempts,` argument with `timeProvider,`; (d) line 114 becomes `.TimeoutAfter(milliseconds, timeProvider);`; (e) the log at lines 119-121 becomes `logger.Error($"{nameof(EtlAsync)} timed out with a timeout of {milliseconds} milliseconds. Canceling");` (drops the false retry count and the banned `DateTime.Now`); (f) in the private seven-parameter `EtlByRowAsync` at lines 229-237 replace `int attempts` with `TimeProvider? timeProvider`, and lines 245 and 259 become `.TimeoutAfter(timeout, timeProvider);`. `EtlAsyncOld` (lines 132-169) and the four-parameter `EtlByRowAsync` (line 171) are not edited (D9, D14). Acceptance, by `Select-String -SimpleMatch` counts on this file: `TimeoutAfter(milliseconds, timeProvider)`=1, `TimeoutAfter(timeout, timeProvider)`=2, `TimeoutAfter(milliseconds, attempts)`=1 (the retained `EtlAsyncOld` line), `TimeoutAfter(timeout, attempts)`=0, `var attempts = 3;`=1, `timed out {attempts} times`=1, `DateTime.Now`=1, `TimeProvider? timeProvider = null`=1, `timed out with a timeout of`=1; line count at most 480. + +- [x] [P1-T2] Edit `UtilitiesCS/Extensions/DfDeedle.cs` `GetEmailDataInViewAsync` (lines 139-144): add a trailing parameter `TimeProvider? timeProvider = null` with a two-line `//` comment; line 176 becomes `await AddQfcColumnsAsync(table, currentFolder!, token, 0, timeProvider: timeProvider);`; the `EtlAsync` call at lines 180-185 gains the named argument `timeProvider: timeProvider`; line 208 becomes `.TimeoutAfter(1000, timeProvider);`. No guard is added in this task. Acceptance by counts on this file: `TimeoutAfter(1000, timeProvider)`=1, `TimeoutAfter(1000, 2)`=1 (the retained commented-out line 214), `timeProvider: timeProvider`=2, `TimeProvider? timeProvider = null`=1, `was not produced`=0. + +- [x] [P1-T3] Edit `UtilitiesCS/Extensions/DfDeedle.cs` to replace the two ETL statics: delete lines 62-84 (`TableEtlInvoker` and `StoreTableEtlInvoker` with their doc comments); add, in their place, one `private static readonly Func columnInfo)> DefaultTableEtl = t => ((Outlook.Table)t).ETL();` with a doc comment stating it is the production ETL and that `object` avoids CS1769; change `GetEmailDataInView(Explorer activeExplorer)` (line 86) to `GetEmailDataInView(Explorer activeExplorer, Func columnInfo)>? etl = null)` and line 94 to `(object[,] data, Dictionary columnInfo) = (etl ?? DefaultTableEtl)(table);`. `MessageBoxInvoker` (lines 54-60) is untouched. Acceptance by counts on this file: `TableEtlInvoker`=0, `DefaultTableEtl` at least 2 (the declaration and the use in `GetEmailDataInView`), `(etl ?? DefaultTableEtl)(table)`=1, `MessageBoxInvoker`=1 (the declaration; its `AddQfcColumns` readers live in the other partial); line count at most 320. + +- [x] [P1-T4] Edit `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs`: `FromDefaultFolder(Store, ...)` (lines 125-130) gains a trailing `Func columnInfo)>? etl = null` and line 143 becomes `(var data, var columnInfo) = (etl ?? DefaultTableEtl)(table);`; `FromDefaultFolder(Stores, ...)` (lines 150-155) gains the same trailing parameter and the inner call at lines 160-165 forwards `etl: etl`. Acceptance by counts on this file: `StoreTableEtlInvoker`=0, `DefaultTableEtl`=1, `etl: etl`=1; line count at most 290. + +- [x] [P1-T5] Edit `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` to consume the new seams: add `using Microsoft.Extensions.Time.Testing;`; in `GetEmailDataInView_WithInjectedEtlResult_ReturnsPopulatedFrame` (line 371) delete the seam swap and `try`/`finally` at lines 399-414 and pass `etl: _ => (injectedData, injectedColInfo)` to `GetEmailDataInView`; in `FromDefaultFolder_Store_WithInjectedEtlResult_ReturnsPopulatedFrame` (line 729) delete lines 762-782's swap and pass the same delegate as `etl:`; in `FromDefaultFolder_Stores_FirstStoreHasData_ReturnsNonEmptyFrame` (line 790) delete lines 827-847's swap and pass `etl:`; in `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` (line 423) add the argument `timeProvider: new FakeTimeProvider()` to the call at lines 477-482 and never advance it (every deadline on the path is then armed on a clock that does not move; the assertions at lines 484-488 are unchanged); rewrite the class header lines 20-33 so line 26 no longer names `TableEtlInvoker` and lines 30-32 read that `MessageBoxInvoker is the one remaining static seam`, every test that swaps it restores it in a `finally`, and it has no reader on any parallel-phase path; rewrite the section-banner and arrangement comments at lines 367, 725, 735 and 796, and the header line 26, so they refer to the optional `etl` parameter written without a trailing colon (the `etl:` count below is therefore the three delegate arguments only) and not to `TableEtlInvoker` or `StoreTableEtlInvoker` (the substring search counts both); Assertions in the three converted tests are unchanged. Acceptance by counts on this file: `TableEtlInvoker`=0, `FakeTimeProvider`=1, `etl:`=3, `MessageBoxInvoker is the one remaining static seam`=1; line count strictly less than 869. + +- [x] [P1-T6] Move `ArmingBarrierTimeProvider` out of `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` into the new file `UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs` as a pure extraction: the new file declares namespace `UtilitiesCS.Test.TestHelpers`, `internal sealed class ArmingBarrierTimeProvider : TimeProvider`, and carries lines 39-85 of the source file verbatim except for the access modifier change from `private` to `internal` and the `using` directives it needs (`System`, `System.Threading`, `System.Threading.Tasks`, `Microsoft.Extensions.Time.Testing`); in the source file delete lines 39-85 and add `using UtilitiesCS.Test.TestHelpers;`. No member body changes. Acceptance: `class ArmingBarrierTimeProvider`=0 in the source file and =1 in the new file; `NewSignal` still resolves at the source file's former lines 95 and 103 (build in P1-T8 proves it); the source file's line count is at most 456 and strictly less than 500; the new file's line count is at most 70. + +- [x] [P1-T7] Register the moved helper in `UtilitiesCS.Test/UtilitiesCS.Test.csproj`: insert `` immediately before the existing line 74 item `TestHelpers\ManualFireInnerTimer.cs`. Acceptance: `Select-String -SimpleMatch 'TestHelpers\ArmingBarrierTimeProvider.cs'` on the project file counts 1; the file's line count is 986. + +- [x] [P1-T8] Build the solution with the analyzer gate and record `/evidence/qa-gates/p1-t8-seam-build.md`: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p1-t8.log;Verbosity=normal"`. Acceptance: `EXIT_CODE: 0`; the log contains ` 0 Error(s)`; the artifact records the warning count and that it does not exceed `BASELINE_ANALYZER_WARNINGS`. This proves the four in-solution callers named in spec.md (`QuickFiler/Controllers/QfcDatamodel.FrameBuilding.cs:15,82-89`, `ToDoModel/Data Model/ID/IDList.cs:130,226`) compile unchanged against the optional parameters. + +- [x] [P1-T9] Scoped run of every class the seams touch, recording `/evidence/regression-testing/p1-t9-seam-scoped-run.md` (PROC-TRX): create `coverage/trx/p1-t9`; run `& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p1-t9.trx" /ResultsDirectory:coverage/trx/p1-t9 $Blame "/TestCaseFilter:FullyQualifiedName~DfDeedle_COM_Tests|FullyQualifiedName~DfDeedleQfcColumnTimeoutTests|FullyQualifiedName~OlTableExtensions_Tests|FullyQualifiedName~TimeOutTask_Tests"`. Acceptance: `EXIT_CODE: 0`; counters recorded; `failed`=0; `Read-TrxOutcome` returns `Passed` for `UtilitiesCS.Test.Extensions.DfDeedle_COM_Tests` / `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` and for `UtilitiesCS.Test.Extensions.DfDeedleQfcColumnTimeoutTests` / `AddQfcColumnsAsync_ThreeDeadlines_InvokesColumnAdderExactlyOnce` (proves the moved barrier still drives the #798 tests). + +### Phase 2 — AC2 regression test, fails first + +- [x] [P2-T1] Create `UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs` (namespace `UtilitiesCS.Test.Extensions`, `[TestClass]`, `[DoNotParallelize]` with a one-line comment that the class drives a real `Task.Run` gate and shares the `DfDeedle` logger like `DfDeedleQfcColumnTimeoutTests`, MSTest + Moq + FluentAssertions, `using UtilitiesCS.Test.TestHelpers;`, `using Microsoft.Extensions.Time.Testing;`) containing private builders that replicate `DfDeedle_COM_Tests` lines 425-476 (one-row strict `Table`, five columns `EntryID`/`MessageClass`/`SentOn`/`ConversationId`/`Triage`, `Row.BinaryToString(4)`, folder built with a `Triage` user-defined property and `Name` returning `Inbox`, strict `TableView` and `Explorer`, `ProgressTracker` over `SilentProgressTracker`) and two tests: (1) `GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder`, arranged per D5 with `var barrier = new ArmingBarrierTimeProvider(new FakeTimeProvider())`, gate A on `Columns.Add("SentOn")` (a `ManualResetEventSlim` waited inside the `Returns` callback), gate B on `GetNextRow()`; act: start the call with `timeProvider: barrier`, `await barrier.Armed`, `barrier.ReArm()`, `gateA.Set()`, `await barrier.Armed`, `barrier.Advance(250)`; assert `await FluentActions.Awaiting(() => call).Should().ThrowAsync()` with `.WithMessage("*Inbox*")`; `finally { gateA.Set(); gateB.Set(); }`; (2) `GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame` with an un-advanced `new FakeTimeProvider()`, no gate engaged, asserting `RowCount` is 1 and `ColumnKeys` contains `EntryId`, `MessageClass`, `ConversationId`. No sleep, delay, retry, or timed wait anywhere in the file. Acceptance: the file exists; `Select-String -SimpleMatch` counts on it: `[TestMethod]`=2, `ArmingBarrierTimeProvider`≥2, `Advance(250)`=1, `ThrowAsync`=1, `Thread.Sleep`=0, `Task.Delay`=0, `CancelAfter`=0, `WaitOne`=0, and with regex `\.Wait\(\s*(\d|TimeSpan)`=0 (the two gates are waited as `gate.Wait()` with no timeout argument, which that regex does not match); line count at most 300. + +- [x] [P2-T2] Register the new test file in `UtilitiesCS.Test/UtilitiesCS.Test.csproj`: insert `` immediately after the item `Extensions\DfDeedleQfcColumnTimeoutTests.cs` (line 191 in the merged base tree; line 192 after P1-T7's insertion). Acceptance: `Select-String -SimpleMatch 'Extensions\DfDeedleEtlTimeoutTests.cs'` on the project file counts 1; line count 987. + +- [x] [P2-T3] Build the test project to produce the assembly, recording `/evidence/regression-testing/p2-t3-red-build.md`: `& $msbuild UtilitiesCS.Test\UtilitiesCS.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU /nologo /v:q "/flp:LogFile=coverage/msbuild-p2-t3.log;Verbosity=normal"`. Acceptance: `EXIT_CODE: 0`; the log contains ` 0 Error(s)`; `Test-Path UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll` prints `True` and its `LastWriteTime` is later than the task start. + +- [x] [P2-T4] [expect-fail] Run the new class and record the fail-before evidence in `/evidence/regression-testing/p2-t4-ac2-fail-before.md` (PROC-TRX): create `coverage/trx/p2-t4`; run `& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p2-t4.trx" /ResultsDirectory:coverage/trx/p2-t4 $Blame "/TestCaseFilter:FullyQualifiedName~DfDeedleEtlTimeoutTests"`. Acceptance: `EXIT_CODE: 1` with `ExpectedExitCode: 1`; counters `total`=2, `passed`=1, `failed`=1; `Read-TrxOutcome` for class `UtilitiesCS.Test.Extensions.DfDeedleEtlTimeoutTests` method `GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder` is `Failed` and the TRX text contains `System.NullReferenceException` inside that result's `ErrorInfo/Message` (FluentAssertions reports the found exception type); method `GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame` is `Passed`; the blame hang timeout did not fire (no `Sequence` file under `coverage/trx/p2-t4`); the artifact quotes the failure message verbatim after redaction. + +### Phase 3 — AC2 guard, passes after + +- [x] [P3-T1] Insert the null-snapshot guard in `UtilitiesCS/Extensions/DfDeedle.cs` between the `EtlAsync` call (ends at the current line 185 region) and the `LogDfTiming(` call (D2): `if (tableSnapshot.data is null) { throw new InvalidOperationException($"The table snapshot for folder '{folderName}' was not produced: the table ETL timed out or was cancelled before returning any rows, so the email data frame cannot be built for this folder."); }` split across lines as CSharpier requires, preceded by a two-line `//` comment that `EtlAsync` swallows its `TimeoutException` and returns a null `data` through a null-forgiving suppression, so this is the first point the failure can be named. `folderName` is already in scope (line 166). The existing `ValidateRequiredEmailColumns` guard (line 195) is untouched and now runs after this one. Acceptance by counts on this file: `was not produced`=1, `tableSnapshot.data is null`=1, `#nullable enable`=1; line count at most 328. + +- [x] [P3-T2] Build and re-run the class, recording `/evidence/regression-testing/p3-t2-ac2-pass-after.md` (PROC-TRX): `& $msbuild UtilitiesCS.Test\UtilitiesCS.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU /nologo /v:q "/flp:LogFile=coverage/msbuild-p3-t2.log;Verbosity=normal"`, then `& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p3-t2.trx" /ResultsDirectory:coverage/trx/p3-t2 $Blame "/TestCaseFilter:FullyQualifiedName~DfDeedleEtlTimeoutTests|FullyQualifiedName~DfDeedle_COM_Tests"`. Acceptance: build `EXIT_CODE: 0` with ` 0 Error(s)`; test `EXIT_CODE: 0`; `failed`=0; `Read-TrxOutcome` is `Passed` for both `DfDeedleEtlTimeoutTests` methods and for `DfDeedle_COM_Tests` / `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform`; the artifact names `p2-t4-ac2-fail-before.md` as the paired fail-before record. + +- [x] [P3-T3] Check off AC2 in `/spec.md` line 293 (`- [ ] AC2:` becomes `- [x] AC2:`, text unchanged), citing `p1-t9-seam-scoped-run.md` (static seams replaced; `TableEtlInvoker`=0 in production and test files) and `p3-t2-ac2-pass-after.md` (guard). Acceptance: `Select-String -SimpleMatch '- [x] AC2:'` on spec.md counts 1 and `'- [ ] AC2:'` counts 0; no other line of spec.md changed (`git diff --numstat HEAD -- docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/spec.md` reports 1 insertion and 1 deletion (`HEAD` is the anchor because spec.md was created on this branch after `$Base` and is absent there; no commit occurs before P7-T12)). + +### Phase 4 — AC1 wall-clock window deletion + +- [x] [P4-T1] Edit `UtilitiesCS/Extensions/DictionaryExtensions.cs` `TryAddValuesAsync` (lines 169-180): delete lines 176-177 (`CreateLinkedTokenSource` and `CancelAfter(500)`) and make the body `return await Task.Run(() => dictionary.TryAddValues(key, value), token);`, preceded by a two-line `//` comment that the body is a bounded compare-and-swap loop and cancellation is governed solely by the caller's token. Acceptance by counts on this file: `CancelAfter(`=0, `CreateLinkedTokenSource`=0, `TryAddValues(key, value), token)`=1; line count at most 282. + +- [x] [P4-T2] Add the contract-lock test to `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs` after `TryAddValuesAsync_UpdatesExistingValue` (line 249): `TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` — arrange a `ConcurrentDictionary` with `["value"] = 8` and a `CancellationTokenSource` that is cancelled before the call; act `Func act = () => dictionary.TryAddValuesAsync("value", 2, cts.Token);`; assert `await act.Should().ThrowAsync()` and `dictionary["value"].Should().Be(8)`. `TryAddValuesAsync_UpdatesExistingValue` is retained unchanged. Acceptance by counts on this file: `TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged`=1, `TryAddValuesAsync_UpdatesExistingValue`=1, `[TestMethod]` increased by exactly 1 over the baseline count recorded in this task's own before-reading; line count at most 320. + +- [x] [P4-T3] Write the AC1 fail-before exception dossier `/evidence/regression-testing/fail-before-exception.2026-09-08T00-00.md` (timestamp replaced by the actual ISO value at authoring; filename prefix `fail-before-exception.` is mandatory) with `Timestamp:`, `WhyFailingRunImpossible:` (the #780 failure is thread-pool scheduling latency above 500 ms under 24-worker coverage load; the only way to force it is a sleep or a deliberately starved pool, both forbidden by AC5 and the unit-test policy), and an alternative proof section containing: the P0-T6 count `CancelAfter(`=1 and `CreateLinkedTokenSource`=1 before P4-T1, the post-edit counts 0 and 0 measured by this task, and the statement that `TryAddValuesAsync` has zero production call sites (D3). Acceptance: the file exists at that path pattern; both `WhyFailingRunImpossible:` and `SearchScope:` lines are present (`SearchScope:` names `/evidence/regression-testing/`, `SearchPatterns: fail-before-exception.*.md`, `SearchResult:` names this file). + +- [x] [P4-T4] Build and run the dictionary class, recording `/evidence/regression-testing/p4-t4-ac1-pass-after.md` (PROC-TRX): `& $msbuild UtilitiesCS.Test\UtilitiesCS.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU /nologo /v:q "/flp:LogFile=coverage/msbuild-p4-t4.log;Verbosity=normal"`, then `& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p4-t4.trx" /ResultsDirectory:coverage/trx/p4-t4 $Blame "/TestCaseFilter:FullyQualifiedName~DictionaryExtensions_Tests"`. Acceptance: build exit 0 with ` 0 Error(s)`; test `EXIT_CODE: 0`; `failed`=0; `Read-TrxOutcome` is `Passed` for class `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests` methods `TryAddValuesAsync_UpdatesExistingValue` and `TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged`; the duration of `TryAddValuesAsync_UpdatesExistingValue` read from its `UnitTestResult/@duration` is under one second. + +- [x] [P4-T5] Check off AC1 in `/spec.md` line 292, citing `fail-before-exception..md` and `p4-t4-ac1-pass-after.md`; the "passes deterministically under 24-worker parallel coverage runs" clause is finally evidenced by P8-T6 and this check-off records that dependency in the plan artifact `/evidence/other/p4-t5-ac1-checkoff-note.md`. Acceptance: `'- [x] AC1:'` counts 1 and `'- [ ] AC1:'` counts 0 in spec.md; the note artifact exists and names P8-T6. + +### Phase 5 — AC3 TextWriter seams + +- [x] [P5-T1] Edit `UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs`: add `using System.IO;`; change `PrintTree(TreeNode node, int level)` (line 97) to `PrintTree(TreeNode node, int level, TextWriter? writer = null)`; line 99 writes through `(writer ?? Console.Out).WriteLine(...)`; the recursion at line 102 forwards `writer`. Acceptance by counts on this file: `TextWriter? writer = null`=1, `writer ?? Console.Out`=1, `PrintTree(child, level + 1, writer)`=1, `Console.WriteLine`=0; line count at most 126. + +- [x] [P5-T2] Edit `UtilitiesCS/HelperClasses/PrettyPrint.cs` with zero net line growth (D10): add `using System.IO;` after line 2; delete line 6 `using System.Threading.Tasks;`, line 12 `using System.Text;` (the namespace-scoped duplicate) and line 18 `using Svg;`; change line 25 to `public static void PrettyPrint(this DataFrame df, TextWriter? writer = null) => (writer ?? Console.Out).WriteLine(PrettyText(df));` and line 27 to `public static void PrettyPrint(this DataFrameRow row, TextWriter? writer = null) => (writer ?? Console.Out).WriteLine(Pretty(row));` (each wraps to two lines under CSharpier). Acceptance by counts on this file: `TextWriter? writer = null`=2, `writer ?? Console.Out`=2, `using Svg;`=0, `using System.Threading.Tasks;`=0, `using System.Text;`=1; line count at most 680 measured both now and again after P7-T1. + +- [x] [P5-T3] Edit `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs`: add `using System.IO;`; change `EnumerateTable(this Outlook.Table table)` (line 381) to `EnumerateTable(this Outlook.Table table, TextWriter? writer = null)`; introduce `var target = writer ?? Console.Out;` and route the three writes at lines 423-425 through `target.WriteLine(...)`. Lines 78 and 96 (`Console.WriteLine($"Task timed out on try {counter}")` inside `GetTableInViewAsync`) are not edited; they are recorded as a follow-up in P8-T9. Acceptance by counts on this file: `TextWriter? writer = null`=1, `writer ?? Console.Out`=1, `Console.WriteLine`=2 (lines 78 and 96 retained), `target.WriteLine`=3; line count at most 436. + +- [x] [P5-T4] Edit `UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs`: add `public static void Run(System.IO.TextWriter writer)` to class `GFG` carrying the body of `Main` (lines 175-191) with the four `Console.WriteLine` calls at lines 187-191 replaced by `writer.WriteLine(...)`; `Main(String[] args)` becomes a one-statement body `Run(Console.Out);`. The three `Console.WriteLine("Stack is empty...")` calls at lines 96, 127 and 137 are inside `pop`/`findMiddle`/`deleteMiddle` and are not part of the seam; they are untouched. Acceptance by counts on this file: `public static void Run(`=1, `Run(Console.Out)`=1, `writer.WriteLine`=4, `Console.WriteLine`=3; line count at most 208. + +- [x] [P5-T5] Edit `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs`: delete the comment block and attribute at lines 9-15 (`[DoNotParallelize]` and its console rationale); rewrite `Main_RunsSampleScenarioWithoutThrowing` (lines 146-168) to invoke `helper.InvokeMain(Array.Empty())` inside `FluentActions.Invoking(...).Should().NotThrow()` with no `Console.Out` capture (it exercises the `Run(Console.Out)` default); add `Run_WritesScenarioToSuppliedWriter`: add `using UtilitiesCS;` to the file's directives, then `using var writer = new StringWriter(); GFG.Run(writer);` (the class is `internal`; `UtilitiesCS/Properties/AssemblyInfo.cs:19` grants `InternalsVisibleTo("UtilitiesCS.Test")`), assert `writer.ToString()` contains `Middle Element :` and `New Middle Element :`. Acceptance by counts on this file: `Console.SetOut`=0, `originalOut`=0, `DoNotParallelize`=0, `Run_WritesScenarioToSuppliedWriter`=1, `Main_RunsSampleScenarioWithoutThrowing`=1, `using UtilitiesCS;`=1; line count at most 285. + +- [x] [P5-T6] Edit `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs`: delete lines 14-19 (comment and `[DoNotParallelize]`); rename `DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput` (line 186) to `DataFramePrettyHelpers_RenderRowsMarkdownAndWriterOutput`, delete the `Console.Out` save/set/restore together with the `try` construct that exists only to host it (lines 194 and 196; the `try` and its opening brace at lines 198-199; the closing brace and `finally` block at lines 215-219), de-indenting the former `try` body one level, and pass `writer` to `frame.PrettyPrint(writer)` and `row.PrettyPrint(writer)`; assertions unchanged; add `PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing` asserting `FluentActions.Invoking(() => { frame.PrettyPrint(); row.PrettyPrint(); }).Should().NotThrow()`. Acceptance by counts on this file: `Console.SetOut`=0, `originalOut`=0, `DoNotParallelize`=0, `PrettyPrint(writer)`=2, `PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing`=1; line count at most 420. + +- [x] [P5-T7] Edit `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs`: delete lines 8-14 (comment and `[DoNotParallelize]`); rename `PrintTree_WritesIndentedTreeToConsole` (line 102) to `PrintTree_WritesIndentedTreeToSuppliedWriter`, delete the save/set/restore together with its `try` construct (lines 108-109; the `try` and its opening brace at lines 111-112; the closing brace and `finally` block at lines 115-119), de-indenting the former body one level, and call `parser.PrintTree(tree, 0, writer)`; the assertion at line 122 is unchanged. The null-writer path of `PrintTree` is already exercised by `UtilitiesCS.Test/EmailIntelligence/ClassifierGroups/Triage/Triage_OlLogicTests.cs:142`. Acceptance by counts on this file: `Console.SetOut`=0, `originalOut`=0, `DoNotParallelize`=0, `PrintTree(tree, 0, writer)`=1; line count at most 118. + +- [x] [P5-T8] Edit `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` for the console seam only: replace the comment at lines 17-19 with one stating that the console reason was removed by the `TextWriter` seam under #811 and the attribute is retained because the class has `not been soaked under class-level parallelism` and ten of its tests drive the 2000 ms `GetTableInViewAsync` window; keep `[DoNotParallelize]` at line 20 (D8); rename `EnumerateTable_WritesFormattedOutputAndMovesToStart` to `EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart`, delete the save/set/restore at lines 1636 and 1638-1646 keeping line 1641 as `mockTable.Object.EnumerateTable(output);`; assertions at lines 1648-1651 unchanged; after line 1650 add one `//` comment line and the single line `FluentActions.Invoking(() => mockTable.Object.EnumerateTable()).Should().NotThrow();` (the null-writer path resolving to `Console.Out`, asserted in the same test because the file is at its line ceiling and cannot take a second method); the existing `mockTable.Verify(t => t.MoveToStart(), Times.AtLeastOnce)` then covers both calls. No new test method is added to this file; the rewritten comment at lines 17-19 occupies at most three lines. Acceptance by counts on this file: `Console.SetOut`=0, `redirects Console.Out`=0, `not been soaked under class-level parallelism`=1, `DoNotParallelize`=1, `EnumerateTable(output)`=1, `EnumerateTable())`=1, `EnumerateTable_NullWriter`=0; line count at most 1848. + +- [x] [P5-T9] Edit `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs`: delete the field at line 17, the save and install at lines 22-23, and the `[TestCleanup]` method at lines 53-57. `PrintLogCall` (lines 46-51) keeps writing to `Console` (whatever writer the process holds; no test asserts on it). Acceptance by counts on this file: `Console.SetOut`=0, `originalOut`=0, `TestCleanup`=0, `PrintLogCall`≥2; line count at most 112. + +- [x] [P5-T10] Build the solution with the analyzer gate and run the five converted classes plus the Triage caller, recording `/evidence/regression-testing/p5-t10-ac3-pass-after.md` (PROC-TRX): `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true /nologo /v:q "/flp:LogFile=coverage/msbuild-p5-t10.log;Verbosity=normal"` (this is where the D10 substitution rule is decided: a CS0103/CS0246 in `PrettyPrint.cs` triggers it, recorded here), then `& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p5-t10.trx" /ResultsDirectory:coverage/trx/p5-t10 $Blame "/TestCaseFilter:FullyQualifiedName~StackGeek_Tests|FullyQualifiedName~PrettyPrint_Tests|FullyQualifiedName~DASLFilterParserTests|FullyQualifiedName~OlTableExtensions_Tests|FullyQualifiedName~NLogTraceWriter_Test|FullyQualifiedName~Triage_OlLogicTests"`. Acceptance: build exit 0 with ` 0 Error(s)` (proves `TaskMaster/Ribbon/TryFunctionalityInConstruction.cs:51,66` and `UtilitiesCS/EmailIntelligence/ClassifierGroups/Triage/Triage_OlLogic.cs:74` compile unchanged); test `EXIT_CODE: 0`; `failed`=0; `Read-TrxOutcome` is `Passed` for the three renamed tests, `Main_RunsSampleScenarioWithoutThrowing`, `Run_WritesScenarioToSuppliedWriter`, and `PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing` (six names); `Select-String -SimpleMatch 'Console.SetOut'` over the five test files of this phase totals 0. + +- [x] [P5-T11] Check off AC3 in `/spec.md` line 294, citing `p5-t10-ac3-pass-after.md`. Acceptance: `'- [x] AC3:'` counts 1 and `'- [ ] AC3:'` counts 0 in spec.md. + +### Phase 6 — AC5 tolerance retirement and ETL clock tests + +- [x] [P6-T1] Edit `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` `EtlAsync_WithBinaryAndObjectFieldsAndProgress_ReturnsTransformedData` (line 947): add `using Microsoft.Extensions.Time.Testing;`; delete lines 960-963 (the comment and `mockTable.Setup(t => t.GetRowCount()).Returns(120);`), so `CreateTableWithColumns` (line 1780) supplies the natural row count of 1 and the deadline is 250 ms; add the trailing argument `timeProvider: new FakeTimeProvider()` to the `EtlAsync` call at lines 971-977 and never advance it. Assertions at lines 979-983 unchanged. Acceptance by counts on this file: `Returns(120)`=0, `cannot fire under test-host`=0, `FakeTimeProvider`=1, `using Microsoft.Extensions.Time.Testing;`=1. + +- [x] [P6-T2] Re-verify the reflection binding in `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` (originally lines 1124-1137) against the edited `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs`, recording `/evidence/other/p6-t2-reflection-binding-check.md`: quote the `InvokeStaticAsync>("EtlByRowAsync", new[] { typeof(Outlook.Table), typeof(Dictionary>), typeof(Dictionary), typeof(CancellationToken) }, ...)` span and the current signature line of the four-parameter private `EtlByRowAsync` in Etl.cs. Acceptance: the four-parameter overload's parameter list is `Table table, Dictionary>? objectConverters, Dictionary columnDictionary, CancellationToken token` (unchanged); the seven-parameter overload's list ends `TimeProvider? timeProvider, ProgressTracker? progress = null`; the artifact records `REFLECTION_BINDING: UNCHANGED`. + +- [x] [P6-T3] Create `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` (namespace `UtilitiesCS.Test.OutlookObjects.Table`, `[TestClass]`, `[DoNotParallelize]` with a one-line comment that it drives a real `Task.Run` gate, `using UtilitiesCS.Test.TestHelpers;`, `using Microsoft.Extensions.Time.Testing;`) with private builders replicating `OlTableExtensions_Tests.CreateTableWithColumns` (lines 1757-1793) and `CreateRowMock` (lines 1723-1750) for the three columns `MessageRecipients`, `Store`, `Subject` (so `Store`, a `BinaryToStringFields` member, selects the `EtlByRowAsync` branch) and two tests: (1) `EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource` — `GetNextRow()` blocks on a gate, `var barrier = new ArmingBarrierTimeProvider(new FakeTimeProvider())`, act: start `EtlAsync(CancellationToken.None, tokenSource, 0, null, converters, barrier)`, `await barrier.Armed`, `barrier.Advance(250)`, await the result; assert `data` is null and `tokenSource.IsCancellationRequested` is true; `finally { gate.Set(); }`; (2) `EtlAsync_ClockNeverAdvances_ReturnsTransformedRows` with an un-advanced `FakeTimeProvider`, asserting `columnInfo["Store"]` is 1, `data[0, 1]` equals the injected binary string and `tokenSource.IsCancellationRequested` is false. Acceptance: file exists; counts: `[TestMethod]`=2, `Advance(250)`=1, `Thread.Sleep`=0, `Task.Delay`=0, `CancelAfter`=0, `WaitOne`=0; line count at most 220. + +- [x] [P6-T4] Register the new test file in `UtilitiesCS.Test/UtilitiesCS.Test.csproj`: insert `` immediately after the item `OutlookObjects\Table\OlTableExtensions_Tests.cs` (line 541 in the merged base tree; line 543 after the P1-T7 and P2-T2 insertions, both of which precede it). Acceptance: `Select-String -SimpleMatch 'OutlookObjects\Table\OlTableExtensionsEtlClockTests.cs'` counts 1; the project file's line count is 988. + +- [x] [P6-T5] Build the test project and run the two table classes, recording `/evidence/regression-testing/p6-t5-etl-clock-pass.md` (PROC-TRX): `& $msbuild UtilitiesCS.Test\UtilitiesCS.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU /nologo /v:q "/flp:LogFile=coverage/msbuild-p6-t5.log;Verbosity=normal"`, then `& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll /InIsolation "/Logger:trx;LogFileName=p6-t5.trx" /ResultsDirectory:coverage/trx/p6-t5 $Blame "/TestCaseFilter:FullyQualifiedName~OlTableExtensions_Tests|FullyQualifiedName~OlTableExtensionsEtlClockTests"`. Acceptance: build exit 0 with ` 0 Error(s)`; test `EXIT_CODE: 0`; `failed`=0; `Read-TrxOutcome` is `Passed` for both `OlTableExtensionsEtlClockTests` methods, for `EtlAsync_WithBinaryAndObjectFieldsAndProgress_ReturnsTransformedData`, and for the reflection test containing `EtlByRowAsync` at the originally cited lines (its method name is read from the file and recorded). + +### Phase 7 — Final QA loop, coverage delta, audits, source commit + +The loop is P7-T1 through P7-T5. If any step fails or P7-T1 rewrites a file, fix the cause and restart from P7-T1; the artifacts of the final clean pass carry `Toolchain pass: `. + +- [x] [P7-T1] Toolchain step 1 in `/evidence/qa-gates/p7-t1-csharpier-format.md`: record PROC-HASH over the 19 write-set `.cs` files and `git status --porcelain --untracked-files=all -- "*.cs" ":(exclude).claude"` before; run `& $dotnet tool run csharpier format .`; record both observations again after. Acceptance: `EXIT_CODE: 0`; `REWRITTEN_COUNT: ` defined as the number of files whose hash differs plus the number of porcelain entries that appeared, never read from the `Formatted N files` line; when `REWRITTEN_COUNT` is greater than 0 the loop restarts at P7-T1 and the final artifact shows `REWRITTEN_COUNT: 0`; `PrettyPrint.cs` line count at most 680 after the pass. + +- [x] [P7-T2] Toolchain step 1 verification in `/evidence/qa-gates/p7-t2-csharpier-check.md`: run `& $dotnet tool run csharpier check .`. Acceptance: `EXIT_CODE: 0`; the summary line is quoted verbatim and begins with `Checked ` and ends with `ms.`. + +- [x] [P7-T3] Toolchain step 2 in `/evidence/qa-gates/p7-t3-msbuild-analyzers.md`: run the P0-T8 command with `"/flp:LogFile=coverage/msbuild-p7-t3.log;Verbosity=normal"`. Acceptance: `EXIT_CODE: 0`; the log contains ` 0 Error(s)`; the warning count does not exceed `BASELINE_ANALYZER_WARNINGS` from P0-T8, and any warning present is enumerated by diagnostic id. + +- [x] [P7-T4] Toolchain step 3 in `/evidence/qa-gates/p7-t4-msbuild-nullable.md`: run the P0-T9 command with `"/flp:LogFile=coverage/msbuild-p7-t4.log;Verbosity=normal"`. Acceptance: `EXIT_CODE: 0`; the log contains ` 0 Error(s)`. + +- [x] [P7-T5] Toolchain step 4, full nine-assembly coverage run, in `/evidence/qa-gates/p7-t5-vstest-coverage.md` (PROC-TRX): create `coverage/trx/p7-t5`; run `dotnet-coverage collect --output coverage/p7-final.cobertura.xml --output-format cobertura --settings coverage.config -- $vstest $Assemblies /Settings:scripts\vscode\TaskMaster.cli.runsettings /InIsolation "/Logger:trx;LogFileName=p7-t5.trx" /ResultsDirectory:coverage/trx/p7-t5 $Blame "/TestCaseFilter:$FullFilter"`. Acceptance: `EXIT_CODE: 0`; counters recorded; `failed`+`error`+`aborted`+`timeout`=0 (any failure, including one of the retained sentinels, is a defect: fix and restart at P7-T1; no re-run-until-green is permitted and the artifact records `RERUN_COUNT: 0`); `total` is greater than the P0-T10 `total` by exactly 7 (the seven new tests in C4; a gap-closure test added by P7-T7 raises this expectation by its count and the artifact of the final pass states the adjusted figure); root `line-rate`, `lines-covered`, `lines-valid`, `branch-rate`, `branches-covered`, `branches-valid` recorded as numbers; `Read-TrxOutcome` is `Passed` for all nine C4 names. + +- [x] [P7-T6] Coverage delta in `/evidence/qa-gates/p7-t6-coverage-delta.md` (PROC-COV over `coverage/p0-baseline.cobertura.xml` and `coverage/p7-final.cobertura.xml`, PROC-CHANGED against `$Base`): for each of the eight production suffixes record baseline and final `valid=`/`covered=`/`uncovered=`; for each, list every post-image added line (from `git diff --unified=0 $Base -- `; all eight files are tracked, so no staging companion is needed here) with `hits=` or `hits=non-executable` when the final map has no entry for that line; record `Get-MethodLineRate` for `StackGeek.cs` method `Run`, and the `EtlAsync` span rate per the P0-T11 rule with the final span read from the edited file (the line containing `> EtlAsync(` through the first later line that is exactly eight spaces followed by `}`); record the repository-wide comparison. Acceptance, every clause: (1) for each of the eight files `uncovered` final is at most `uncovered` baseline; (2) every added executable line reports `hits` greater than 0; (3) `Run` line-rate is at least 0.90 (new method, CLAUDE.md UT2); (4) the `EtlAsync` span rate final (covered divided by valid) is at least the P0-T11 `ETLASYNC_SPAN_RATE` (the expiry test newly covers the `catch` block), with both spans recorded; (5) repository-wide: exactly one of `COMPARABILITY: A` (final `lines-valid` within 1 percent of baseline `lines-valid`; then final `line-rate` is at least baseline minus 0.005) or `COMPARABILITY: B` (denominators differ by more than 1 percent; the two rates are recorded and not gated, with one sentence saying why) is named; (6) the artifact carries `Timestamp:`, `Command:`, `EXIT_CODE: 0`, `Output Summary:` with the numeric headline for both documents. + +- [x] [P7-T7] Gap closure for P7-T6 in `/evidence/qa-gates/p7-t7-gap-closure.md`: if every P7-T6 clause passed, record `GAP CLOSURE: NOT REQUIRED` and touch nothing. Otherwise add the smallest test that exercises each uncovered added line, in the write-set test file that owns the production file (`DfDeedleEtlTimeoutTests.cs` or `OlTableExtensionsEtlClockTests.cs` for the two seam files; `DictionaryExtensions_Tests.cs`, `StackGeek_Tests.cs`, `PrettyPrint_Tests.cs`, `DASLFilterParserTests.cs`, `OlTableExtensions_Tests.cs` for the rest; never `DfDeedleQfcColumnTimeoutTests.cs`), name each added test in the artifact, and restart the loop at P7-T1. Acceptance: the artifact exists and its final state records either `NOT REQUIRED` or the list of added fully-qualified test names together with a passing re-run of P7-T5 and P7-T6. + +- [x] [P7-T8] File-size audit in `/evidence/qa-gates/p7-t8-line-cap.md`: `(Get-Content).Count` for the 19 write-set `.cs` files after the final format pass. Acceptance: every file other than the three pre-existing violations is at most 500; `UtilitiesCS/HelperClasses/PrettyPrint.cs` is at most 680; `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` is strictly less than 869; `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` is at most 1848 (the P5-T8 ceiling; P6-T1 is a further net −2); `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` is strictly less than 500; `UtilitiesCS/Threading/TimeOutTask.cs` is exactly 1011 (untouched); the project file is recorded as exempt from the cap. + +- [x] [P7-T9] Write-set scope audit in `/evidence/qa-gates/p7-t9-write-set-diff.md`: run `git add --intent-to-add -- UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs`, then `git diff --name-only $Base -- "*.cs" "*.csproj" "*.config" "*.props" "*.targets" "*.runsettings" "*.yml" ":(exclude).claude"` and `git status --porcelain --untracked-files=all -- . ":(exclude).claude"`. Acceptance: the name-only list, sorted, equals exactly the 20 Write Set paths (no fewer: every one is touched; no more: no path outside the set); the porcelain output lists no path outside the 20 paths plus `/` plus `docs/features/potential/`; `git diff --numstat $Base -- UtilitiesCS/Threading/TimeOutTask.cs UtilitiesCS/Extensions/DfDeedle.QfcColumns.cs` is empty. + +- [x] [P7-T10] AC5 timing-hack search in `/evidence/qa-gates/p7-t10-ac5-timing-hack-search.md`: over `git diff $Base -- "*.cs" ":(exclude).claude"` keep only added lines (`^\+` and not `^\+\+\+`) and count, with `Select-String -SimpleMatch`, each of: `Thread.Sleep`, `Task.Delay`, `CancelAfter`, `WaitOne`, `Returns(120)`, `SpinWait`, `Stopwatch`; count with regex the shapes `new CancellationTokenSource\(\s*\d` (a numeric timeout), `\.Wait\(\s*(\d|TimeSpan)` (a timed wait), `catch \(` restricted to hunks of files under `UtilitiesCS.Test/` (a retry loop needs a catch; the new tests use `finally` only), and `Returns\(\s*\d{2,}\s*\)` on a line that also contains `GetRowCount` (a mock value chosen to outrun a deadline). Acceptance: every count is 0 (the production files' existing stopwatches are not added lines, so `Stopwatch` is 0 as well); the artifact lists each pattern with its count; a separate `RETIRED:` line records that `Returns(120)` and `cannot fire under test-host` count 0 in the final tree of `OlTableExtensions_Tests.cs`; a `RETAINED_GATES:` line records that `gate.Wait()` (no argument) occurrences in the two new test files are release-by-test gates, not timed waits, with their count. + +- [x] [P7-T11] Check off AC5 in `/spec.md` line 296, citing `p7-t10-ac5-timing-hack-search.md`. Acceptance: `'- [x] AC5:'` counts 1 and `'- [ ] AC5:'` counts 0 in spec.md. + +- [x] [P7-T12] Source commit recording `/evidence/other/p7-t12-source-commit.md`: `git add -A -- . ":(exclude).claude"`, then `git commit -m "fix(811): make UtilitiesCS.Test deterministic under parallel coverage (AC1-AC3, AC5)"` with a body listing the three defects; after the commit, write the artifact with `git rev-parse HEAD` on its own line as `SOURCE-HEAD: ` (the artifact is therefore the one untracked path until P8-T12 commits it). Acceptance: exit 0; `git status --porcelain --untracked-files=all -- . ":(exclude).claude"` lists exactly one path, this task's artifact; `git status --porcelain -- "*.cs" "*.csproj"` is empty; `git diff --name-only $Base..HEAD -- "*.cs" "*.csproj"` lists exactly the 20 Write Set paths; the commit message contains no absolute path, account name or machine name. + +### Phase 8 — AC4 ten-run gate, sanitisation, follow-ups, final commit + +Every run in P8-T1 through P8-T5 uses this exact shape, with `$Shape` read from `p0-t12-ci-shape-probe.md` (`Select-String -Pattern '^AC4_COMMAND_SHAPE: (CI-VERBATIM|RUNSETTINGS-FALLBACK)$'`): base arguments `$Assemblies /EnableCodeCoverage /InIsolation "/Logger:trx;LogFileName=-run.trx" /ResultsDirectory:coverage/trx/ $Blame "/TestCaseFilter:$FullFilter"`, plus `/Settings:TaskMaster.runsettings` only when `$Shape` is `RUNSETTINGS-FALLBACK`. Each pair task: (a) records `git rev-parse HEAD` and asserts it equals `SOURCE-HEAD` from P7-T12 and that `git status --porcelain -- "*.cs" "*.csproj"` is empty (the ten runs are consecutive on one unchanged tree); (b) runs the two runs back to back inside one `pwsh -NoProfile -File coverage/plan811-helper.ps1` invocation with the tool timeout set to 600000 ms (expected 2 to 6 minutes per pair; `PROBE_SECONDS` from P0-T12 times two is the estimate); (c) reads both TRX counters with PROC-TRX; (d) deletes every `*.coverage` file under its results directory after reading; (e) writes `/evidence/regression-testing/-ac4-runs.md` with `Timestamp:`, `Command:`, `EXIT_CODE:` (the second run's, and the first run's on a separate line), `Output Summary:`. If the invocation is cut off by the tool timeout, the missing TRX is `RUN_INCOMPLETE`; the task is re-run once as two single-run invocations of the same command, and the artifact records the split. Acceptance for each pair task: both TRX files exist; both `failed`=0, `error`=0, `aborted`=0, `timeout`=0, `executed`=`total`; both `total` values equal the P7-T5 `total`; both exit codes are 0. + +- [x] [P8-T1] AC4 runs 1 and 2 into `coverage/trx/p8-t1` (`p8-t1-run1.trx`, `p8-t1-run2.trx`), artifact `/evidence/regression-testing/p8-t1-ac4-runs.md`. Acceptance: the pair acceptance above; additionally `Read-TrxOutcome` on `p8-t1-run1.trx` is `Passed` for all nine C4 names (the non-vacuity control: an unregistered or undiscovered test would read `ABSENT`, and `ABSENT` fails this clause). + +- [x] [P8-T2] AC4 runs 3 and 4 into `coverage/trx/p8-t2`, artifact `/evidence/regression-testing/p8-t2-ac4-runs.md`. Acceptance: the pair acceptance. + +- [x] [P8-T3] AC4 runs 5 and 6 into `coverage/trx/p8-t3`, artifact `/evidence/regression-testing/p8-t3-ac4-runs.md`. Acceptance: the pair acceptance. + +- [ ] [P8-T4] AC4 runs 7 and 8 into `coverage/trx/p8-t4`, artifact `/evidence/regression-testing/p8-t4-ac4-runs.md`. Acceptance: the pair acceptance. + +- [x] [P8-T5] AC4 runs 9 and 10 into `coverage/trx/p8-t5`, artifact `/evidence/regression-testing/p8-t5-ac4-runs.md`. Acceptance: the pair acceptance. + +- [ ] [P8-T6] Aggregate the ten runs into `/evidence/regression-testing/p8-t6-ac4-ten-run.md`: a ten-row table (run, total, executed, passed, failed, error, aborted, timeout, wall-clock seconds read from each TRX's `Times/@start` and `@finish`), the `SOURCE-HEAD` all five pair tasks recorded, the `AC4_COMMAND_SHAPE` used, the filter used with the sentence that the shell-icon exclusion is a documented local environmental exclusion covered by CI and that the CI run on the pull request supplies the unfiltered single-run form (one CI run is not ten), and the local worker count (`[Environment]::ProcessorCount`) since `Workers = 0` resolves to it. Acceptance: exactly ten rows; every `failed`, `error`, `aborted`, `timeout` is 0; every `passed` equals its `total`; all ten `total` values are identical and equal the P7-T5 `total`; the five `SOURCE-HEAD` values are identical; the artifact contains no account name, machine name or absolute path (checked with the run-time-derived tokens, expected count 0); `Timestamp:`, `Command:`, `EXIT_CODE: 0`, `Output Summary:` present. + +- [ ] [P8-T7] Check off AC4 in `/spec.md` line 295, citing `p8-t6-ac4-ten-run.md`. Acceptance: `'- [x] AC4:'` counts 1 and `'- [ ] AC4:'` counts 0 in spec.md. + +- [x] [P8-T8] Host-token sanitisation sweep over `/evidence/**` (contents and names) recording `/evidence/qa-gates/p8-t8-sanitisation.md`: with `$acct` and `$mach` derived at run time (C2), count `@(Get-ChildItem -Recurse -File -Path $Feature/evidence | Select-String -Pattern "(?i)$acct").Count`, the same for `$mach`, the drive-rooted profile-path shape `[A-Za-z]:[^A-Za-z0-9]Users[^A-Za-z0-9]` (written without backslashes so this document stays free of them) as a single-quoted string, and the count of file or directory names under `$Feature/evidence` containing either token; rewrite any offending content to the ``, ``, `` tokens and record per-file substitution counts (the rewrite exits 0 either way, so the counts are the observation). Acceptance: all four final counts are 0; the artifact never writes either token's value; the sweep covers every artifact produced by P0-T1 through P8-T7, and the artifacts P8-T9 through P8-T11 produce are written from plan text only and are re-counted by P8-T12. + +- [x] [P8-T9] Author the two follow-up potential entries under `docs/features/potential/` using the bug-template heading set of `/issue.md` lines 14-83 (Summary through Next Step, `- Status: Draft`, `- Date captured: `): (1) `docs/features/potential/-etl-deadline-mechanics-follow-ups.md` covering the 250 ms-per-row budget at `OlTableExtensions.Etl.cs:81`, the residual 2000 ms `GetTableInViewAsync` window seamed by a factory rather than a `TimeProvider`, `EtlAsync`'s null-through-suppression tuple contract (nullable `data` plus rethrown `TimeoutException`), deletion of the inert `(int, int)` `TimeoutAfter` overloads at `TimeOutTask.cs:824` and `:924` (also recorded by #798 `evidence/other/followup-promotions.md` Finding 1), the stale `TableEtlInvoker` mention in the `DfDeedle.QfcColumns.cs:96` doc comment, and removal of `[DoNotParallelize]` from `OlTableExtensions_Tests` after a parallel soak; (2) `docs/features/potential/-console-out-aggressors-and-banned-symbol-promotion.md` covering the roughly 24 test classes that install a `DebugTextWriter` with no restore, the two production `Console.WriteLine` diagnostics at `OlTableExtensions.TableAccess.cs:78,96` that should use the logger, and promotion of RS0030 from `suggestion` to `warning` after legacy cleanup. Acceptance: both files exist; each contains the line `- [ ] Promote to GitHub issue (bug-report template)`; neither contains an absolute host path; the promotion itself is the orchestrator's step through the `feature-promotion-lifecycle` route and is not performed here. + +- [ ] [P8-T10] Write the issue-update mirror `/evidence/issue-updates/issue-811..md` with `Timestamp:`, the exact text intended for the issue (the AC status summary in the `acceptance-criteria-tracking` format with `Source: spec.md`, `Total AC items: 5`, `Checked off (delivered): 5`, `Remaining (unchecked): 0`, the P8-T6 ten-row table, the two follow-up entries by path, and the closure pointers "#780, #803 and #594 are superseded by #811"), and the header `POSTING BLOCKED: GitHub posting is gated to the orchestrator by repository hooks` with `PostedAs: unknown`. Acceptance: the file exists; contains `Total AC items: 5` and `Checked off (delivered): 5`; contains no host token. + +- [ ] [P8-T11] Final AC reconciliation in `/evidence/other/p8-t11-ac-reconciliation.md`: read `/spec.md` lines 292-296. Acceptance: `Select-String -SimpleMatch '- [x] AC'` over spec.md counts 5 and `'- [ ] AC'` counts 0; the artifact lists each AC with the evidence artifact that discharged it (AC1: fail-before dossier + p4-t4 + p8-t6; AC2: p1-t9 + p2-t4 + p3-t2; AC3: p5-t10; AC4: p8-t6; AC5: p7-t10) and the "Acceptance Criteria Status" block. + +- [x] [P8-T12] Final commit and clean-tree gate recording `/evidence/other/p8-t12-final-commit.md`: first write the artifact with the P8-T8 four counts re-run over `$Feature` and `docs/features/potential/` (expected 0, 0, 0, 0) and the intended commit message; then `git add -A -- . ":(exclude).claude"`; then `git commit -m "docs(811): AC4 ten-run evidence, sanitisation, follow-ups, AC reconciliation"`. The resulting sha is reported in the executor's completion message, not written to any artifact, so nothing is written after the commit. Acceptance: exit 0; `git status --porcelain --untracked-files=all -- . ":(exclude).claude"` is empty; `git diff --name-only $Base..HEAD -- "*.cs" "*.csproj"` still lists exactly the 20 Write Set paths; `git ls-files -- $Feature/evidence | Measure-Object` counts at least 40 tracked artifacts and includes `p8-t6-ac4-ten-run.md`, `fail-before-exception.` (one file), and `p2-t4-ac2-fail-before.md`; no committed path under `$Feature/evidence` ends in `.trx`, `.coverage` or `.cobertura.xml`. + +--- + +## Plan deviations recorded against spec.md prose (no acceptance criterion is changed) + +- The "token cancelled after the work is observed to start" test (spec Test Strategy, AC1 bullet 3) is not authored; see D6. +- `EtlAsyncOld` is retained; spec.md offers deletion as an alternative to a documentation budget, and the budget is met without it (D9). +- `[DoNotParallelize]` is retained on `OlTableExtensions_Tests` under spec Mitigation 4 (D8); the console reason is removed from its comment and the retention is filed as a follow-up. +- `NLogTraceWriter_Test.cs` loses its `DebugTextWriter` install as well as its save/restore (P5-T9); the spec asks for the save/restore removal, and deleting the now-purposeless install is the smaller resulting file. +- Raw Cobertura documents are not committed (D15); numeric figures are recorded in the artifacts. + +--- + +## Adversarial self-review + +This section covers two passes. Round 1 (initial authoring) re-derived every citation the plan +relies on against this worktree with Read, Grep and Glob. Round 2 (this pass, the revision that +applied the nine-defect preflight delta) re-derived, against the current tree, every citation the +delta's edits touched, and re-read the sibling lines and clauses of each: `OlTableExtensions_Tests.cs` +lines 14-22, 958-978 and 1625-1656 plus a whole-file search for `EnumerateTable(`; +`OlTableExtensions.Etl.cs` lines 60-134 plus a whole-file search for `> EtlAsync(` and for lines +that are exactly eight spaces followed by `}`; `PrettyPrint_Tests.cs` lines 184-221; +`DASLFilterParserTests.cs` lines 100-125; `StackGeek_Tests.cs` lines 1-20 and 178-193 plus its +`using` directives; `DfDeedle_COM_Tests.cs` lines 1-19 plus a whole-file search for `TableEtlInvoker`; +the feature folder listing; `.editorconfig` for `IDE0005`; and a project-wide count of +`using UtilitiesCS;` in `UtilitiesCS.Test`. No source file has been edited between the two passes, +so the round-1 citations that the delta did not touch describe the same tree; executor preflight +round 1 independently confirmed every cited line range against it. + +Round 2 findings recorded for the caller: + +- The P5-T10 parenthetical was `(seven names)` in the delta and was applied verbatim, then reported as a miscount. The list it qualifies enumerates three renamed tests plus `Main_RunsSampleScenarioWithoutThrowing`, `Run_WritesScenarioToSuppliedWriter` and `PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing`, which is six names. The orchestrator corrected line 371 to `(six names)` after confirming the enumeration directly. +- Defect 6's premise is narrower than stated: `StackGeek_Tests.cs` declares `namespace UtilitiesCS.Test.ReusableTypeClasses`, an enclosing-namespace lookup already resolves `UtilitiesCS.GFG` as `GFG` without a directive. The directive is nevertheless harmless and matches repository practice: 104 files under `UtilitiesCS.Test` already carry `using UtilitiesCS;` (including `DfDeedle_COM_Tests.cs` line 14 in a namespace that is likewise nested under `UtilitiesCS`), and `.editorconfig` has no `IDE0005` entry, so the redundant directive raises no build diagnostic and the `P7-T3` warning ceiling is unaffected. +- D13 previously stated that "P8-T6 carries a non-vacuity control naming every new test"; the control is in fact the `Read-TrxOutcome` clause of P8-T1, and P8-T6 aggregates counters only. D13 was not in the delta, so the orchestrator corrected the attribution to P8-T1 after confirming it against P8-T1 and P8-T6. +- The new single-line assertion `FluentActions.Invoking(() => mockTable.Object.EnumerateTable()).Should().NotThrow();` is 84 characters plus a 12-space indent (96 columns), so CSharpier keeps it on one line and the P5-T8 arithmetic (−9 +2 = −7, ceiling 1848) holds. + +Round 1 findings that changed the plan: + +- `EtlAsyncOld` (Etl.cs 132-169) and the commented-out line `DfDeedle.cs:214` retain the old tokens, so every zero-hit gate over `attempts`, `DateTime.Now` and `TimeoutAfter(1000, 2)` was rewritten as an exact-count gate. +- `Columns.Add("SentOn")` at `DfDeedle.QfcColumns.cs:40` is the first call inside `AddQfcColumns`, which gives the RED test a deterministic first gate; without it the number of `Armed` signals before the ETL timer is 1 or 2 depending on a race. +- `PrettyPrint.cs` has no `System.IO` using and both seamed overloads wrap under the 100-column width, so "zero net growth" needed three named removals, verified by token probes (no `Task`, no `Svg`, duplicate `System.Text` at line 12 versus line 5). +- The `Key` hits in `PrettyPrint.cs` (lines 174, 535) are property reads, not `System.Windows.Input.Key`, which is why line 15 is the substitution candidate and not a primary removal. +- `Triage_OlLogicTests.cs:142` already exercises the null-writer `PrintTree` path, so no extra null-writer test is added for DASL. +- `UtilitiesCS/Properties/AssemblyInfo.cs:19` grants `InternalsVisibleTo("UtilitiesCS.Test")`, so `GFG.Run` can be called directly from the test without reflection. +- `CreateTableWithColumns` (`OlTableExtensions_Tests.cs:1779-1780`) already derives `GetRowCount()` from the row count, so retiring `Returns(120)` is a pure deletion of four lines. +- `TimeOutTask_Tests.cs:197,210` still call the `(int, int)` overload; P1-T9 runs that class to prove the untouched overloads still bind. +- The dot-claude agent-memory tree is tracked; every git gate is pathspec-scoped with the exclusion. +- No project-file line count enters the 500-line audit; `UtilitiesCS.Test.csproj` is 985 in the merged base tree and grows by exactly three items. + +SELF-REVIEW: RE-DERIVED THIS PASS + +Round 2 (this pass), one entry per citation the delta touched: + +- `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` | 17-19 (comment naming `redirects Console.Out`), 20 (`[DoNotParallelize]`), 1635 (`var output = new StringWriter();`), 1636 (`var original = Console.Out;`), 1638-1640 (`try`, brace, `Console.SetOut(output);`), 1641 (`mockTable.Object.EnumerateTable();`, the only `EnumerateTable(` occurrence in the file), 1642-1646 (`}`, `finally` block), 1648-1651 (three `Contain` assertions and the `MoveToStart` verify); line count 1855 +- `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` | 960-963 (comment and `Returns(120)`), 971-977 (`EtlAsync` call, one argument per line); P6-T1 net −2 confirmed +- `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` | 66 (the only line containing `> EtlAsync(`), 130 (first line after 66 that is exactly eight spaces followed by `}`; the intervening closers at 116 and 123 are twelve-space), 117-123 (`catch (TimeoutException)`), 132 (`EtlAsyncOld` opens; excluded from the span) +- `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs` | 186 (test name), 194 (`var originalOut = Console.Out;`), 195 (`using var writer`), 196 (`Console.SetOut(writer);`), 198-199 (`try`, `{`), 204-205 (`frame.PrettyPrint();`, `row.PrettyPrint();`), 215-219 (`}`, `finally`, `{`, `Console.SetOut(originalOut);`, `}`); line count 408 +- `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs` | 102 (test name), 108-109 (`var originalOut`, `Console.SetOut(writer);`), 111-112 (`try`, `{`), 114 (`parser.PrintTree(tree, 0);`), 115-119 (`}`, `finally`, `{`, `Console.SetOut(originalOut);`, `}`), 122 (assertion); line count 125 +- `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs` | 1-5 (the five `using` directives; no `using UtilitiesCS;`), 7 (`namespace UtilitiesCS.Test.ReusableTypeClasses`), 9-15 (comment and `[DoNotParallelize]`), 187-188 (`typeof(UtilitiesCS.StackObjectCS).Assembly` and `GetType("UtilitiesCS.GFG", ...)`); line count 276 +- `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` | 14 (`using UtilitiesCS;`), 26 (header mention), 367, 725, 735, 796 (comments naming `TableEtlInvoker` or `StoreTableEtlInvoker`), 399-400, 413, 762-763, 781, 827-828, 846 (the swap statements); `TableEtlInvoker` substring count 14 +- `UtilitiesCS.Test` | 104 files carry `using UtilitiesCS;` (project-wide count) +- `.editorconfig` | no `IDE0005` entry +- `/` | contains `issue.md`, `spec.md`, `plan.2026-09-07T22-03.md` only; `spec.md` is tracked at `HEAD` and dated to this branch's creation, so `HEAD` is a valid numstat anchor for P3-T3 (existence at `$Base` is denied by the caller's preflight and is not independently checkable from this planner's tool surface, which has no shell) +- `plan.2026-09-07T22-03.md` | the words `ten`, `eight new`, `P8-T6` searched document-wide; the surviving `ten` occurrences (D8 "ten of its tests", Phase 8 heading "ten-run", P8-T6 "ten rows") count tests in a class or AC4 runs and are unrelated to the C4 sentinel set + +Round 1, retained (the delta did not touch these and no source file has changed since): + +- `UtilitiesCS/Extensions/DictionaryExtensions.cs` | lines 123-136 (`TryAddValues`), 169-180 (`TryAddValuesAsync`), 176-177 (linked source and `CancelAfter(500)`); line count 282 +- `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs` | line 237 (test name), 244 (only invocation); line count 296 +- `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` | 66-73 (`EtlAsync` signature), 81-82, 98-106, 114, 117-123, 129, 132-169 (`EtlAsyncOld`), 171 (four-parameter `EtlByRowAsync`), 229-237 (seven-parameter), 245, 259; line count 474 +- `UtilitiesCS/Extensions/DfDeedle.cs` | 54-60 (`MessageBoxInvoker`), 62-84 (two statics), 86-102 (`GetEmailDataInView`), 94, 139-144, 166, 176, 180-185, 186-189 (call opens 186, dereference 188), 195, 204-208, 214 (commented-out `TimeoutAfter(1000, 2)`); line count 314 +- `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs` | 125-148 (`FromDefaultFolder(Store)`), 143, 150-165 (`Stores` overload); line count 276 +- `UtilitiesCS/Extensions/DfDeedle.QfcColumns.cs` | 23-86 (`AddQfcColumns`, first call `Columns.Add("SentOn")` at 40), 96 (doc comment naming `TableEtlInvoker`), 102-109 (`AddQfcColumnsAsync` with `timeProvider`), 119, 127, 158-168; line count 297 +- `UtilitiesCS/Threading/TimeOutTask.cs` | 824-849 (`(int, int)` overload; catch at 836, retry at 841), 862-874 (`(int, TimeProvider?)` overload; `return task` at 873); line count 1011 +- `UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs` | 97-104 (`PrintTree`), 99; line count 122; no `TextWriter` +- `UtilitiesCS/HelperClasses/PrettyPrint.cs` | 1-18 (usings; duplicate `System.Text` at 5 and 12), 25, 27; token probes for `Task`, `Svg`, `Outlook`, `Input` types; line count 680 +- `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs` | 1-12 (usings), 78, 96, 381-428 (`EnumerateTable`), 423-425; line count 430 +- `UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs` | 1-16 (usings, `class GFG` internal), 96, 127, 137, 173-192 (`Main`); line count 199 +- `UtilitiesCS/Properties/AssemblyInfo.cs` | 19 (`InternalsVisibleTo("UtilitiesCS.Test")`) +- `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` | 20-33 (header), 197-211 (`MessageBoxInvoker` swap shape), 371, 399-414, 423-489 (the #803 test; `GetRowCount` 1 at 442, call at 477-482), 729, 762-782, 790, 827-847, 850-853 (`CreateProgressTracker`); `TableEtlInvoker` count 14; line count 869 +- `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` | 8 (`using Microsoft.Extensions.Time.Testing`), 21-22 (`[DoNotParallelize]`), 39-85 (`ArmingBarrierTimeProvider`), 95, 103, 130-141 (`FireOneDeadlineAsync`), 266-293 (barrier usage), 500 (last line); line count exactly 500 +- `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` | 1-13 (usings; no `Time.Testing`), 17-22 (comment and attribute), 946-984 (test with `Returns(120)` at 963, comment at 960-962), 986-1017 (`EtlAsyncOld` test), 1124-1137 (reflection binding), 1635-1651 (console capture), 1715-1716, 1723-1750, 1757-1793 (`effectiveRowCount` at 1779-1780), 1813-1822; line count 1855 +- `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs` | 9-17, 146-168; line count 276 +- `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs` | 14-21, 185-220; line count 408 +- `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs` | 8-16, 101-123; line count 125 +- `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs` | 17, 19-29, 46-57; line count 119 +- `UtilitiesCS.Test/EmailIntelligence/ClassifierGroups/Triage/Triage_OlLogicTests.cs` | 142 (`PrintTree(logicTree, 0)`) +- `UtilitiesCS.Test/Threading/TimeOutTask_Tests.cs` | 197, 210 (`TimeoutAfter(100, 3)`) +- `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | 74-77 (`TestHelpers\` items), 189-192 (`DfDeedle` family), 541 (`OlTableExtensions_Tests.cs`); line count 985 +- `UtilitiesCS.Test/Properties/AssemblyInfo.cs` | 18-21 (`Parallelize(Workers = 0, Scope = ClassLevel)`) +- `UtilitiesCS.Test/TestHelpers/ManualFireTimerWrapper.cs` | 4 (namespace `UtilitiesCS.Test.TestHelpers`) +- `QuickFiler/Controllers/QfcDatamodel.FrameBuilding.cs` | 15, 82-89, 96-108 +- `ToDoModel/Data Model/ID/IDList.cs` | 130 (named-argument call) +- `TaskMaster/Ribbon/TryFunctionalityInConstruction.cs` | 51, 66 (from repository grep) +- `UtilitiesCS/EmailIntelligence/ClassifierGroups/Triage/Triage_OlLogic.cs` | 74 (from repository grep) +- `.github/workflows/_mstest-coverage.yml` | 86-92 (discovery), 99 (invocation); `timeout-minutes: 30` per spec.md +- `scripts/vscode/Invoke-MSTestWithCoverage.ps1` | 1-13 (parameters), 70-76 (argument shape, hard-coded filter), 296-307 (discovery, `\.claude\` rejection at 301), 340-345, 348 +- `scripts/vscode/Invoke-Restore.ps1` | 1-10 (parameters), 36 +- `scripts/vscode/Install-RepoDotNetSdk.ps1` | 1-11 (parameters) +- `global.json` | 1-12 (`8.0.205`, `.dotnet-sdk`) +- `TaskMaster.runsettings` | 1-30 (parallelize and module excludes); `scripts/vscode/TaskMaster.cli.runsettings` exists (Glob) +- `.gitignore` | 44, 57, 140-145 (`*.coverage`, `coverage/*`), 350 +- `.editorconfig` | 546-548 (RS0030 at `suggestion`) +- `coverage.config` | exists at the repository root (Glob) +- `docs/features/active/2026-09-06-quickfiler-crash-column-add-timeout-swallowed-keynotfound-798/evidence/baseline/shell-icon-stall-probe.md` | verdict `SHELL_ICON_EXCLUSION: REQUIRED` and the filter extension +- `docs/features/active/2026-09-06-quickfiler-crash-column-add-timeout-swallowed-keynotfound-798/evidence/baseline/vstest-coverage-baseline.md` | command shape, 7023 tests, 54.5 s, nine-assembly list +- `docs/features/active/2026-09-06-quickfiler-crash-column-add-timeout-swallowed-keynotfound-798/evidence/baseline/csharpier-check-baseline.md` | `Checked 1587 files in 7826ms.` +- `/issue.md` | 12 (`Work Mode: full-bug`), 14-83 (bug-template headings) +- `/spec.md` | 13, 52-57, 66-72, 135, 139-143, 151-182, 259-288, 291-296 (AC1-AC5), 298-318, 325-329 +- `/research/root-cause.2026-09-07T22-10.md` | Sections 1-5 and Numeric Derivation Evidence (read in full) + +--- + +## Planner Internal Review Record + +The two write-set paths whose directory name contains a space (`UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs`, lines 97-104, and `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs`, lines 8-14 and 101-123) were re-derived in this pass and appear in the enumeration above; they are omitted from the `CITATION:` lines below only because the record format requires a space-free path token. + +PLANNER-INTERNAL-REVIEW: PASS +CITATION-TO-TREE: PASS +AC-TRACEABILITY: PASS +SCOPE-BOUNDARY: PASS +CITATION: UtilitiesCS/Extensions/DictionaryExtensions.cs | lines 169-180; CancelAfter(500) at 177; TryAddValues 123-136 +CITATION: UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs | EtlAsync 66-130 (sole "> EtlAsync(" line 66; eight-space closer 130); catch 117-123; EtlAsyncOld 132-169; EtlByRowAsync 171 and 229-263; TimeoutAfter at 114, 245, 259 +CITATION: UtilitiesCS/Extensions/DfDeedle.cs | statics 62-84; GetEmailDataInView 86-102; GetEmailDataInViewAsync 139-219; LogDfTiming opens 186, dereference 188; TimeoutAfter(1000, 2) at 208 and comment 214 +CITATION: UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs | FromDefaultFolder(Store) 125-148, StoreTableEtlInvoker read at 143; Stores overload 150-165 +CITATION: UtilitiesCS/Extensions/DfDeedle.QfcColumns.cs | AddQfcColumns 23-86 (Columns.Add("SentOn") at 40); AddQfcColumnsAsync 102-109; TimeoutAfter(3000, timeProvider) at 127; doc comment 96 +CITATION: UtilitiesCS/Threading/TimeOutTask.cs | (int, int) overload 824-849; (int, TimeProvider?) overload 862-874 +CITATION: UtilitiesCS/HelperClasses/PrettyPrint.cs | usings 1-18; PrettyPrint overloads 25 and 27; line count 680 +CITATION: UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs | EnumerateTable 381-428; Console.WriteLine at 78, 96, 423-425 +CITATION: UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs | GFG.Main 173-192; Console.WriteLine at 96, 127, 137, 187-191 +CITATION: UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs | using UtilitiesCS at 14; header 20-33; seam comments 367, 725, 735, 796; seam swaps 399-414, 762-782, 827-847; failing test 423-489; line count 869 +CITATION: UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs | ArmingBarrierTimeProvider 39-85; line count exactly 500 +CITATION: UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs | attribute 17-20; Returns(120) 960-963; EtlAsync call 971-977; reflection 1124-1137; console capture 1635-1651 (sole EnumerateTable( call at 1641); builders 1723-1793; line count 1855 +CITATION: UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs | usings 1-5 (no using UtilitiesCS); attribute 9-15; console capture 146-168; reflection 187-188 +CITATION: UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs | attribute 14-19; console capture 185-220 (try 198-199; finally 215-219) +CITATION: UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs | field 17; save/install 22-23; TestCleanup 53-57 +CITATION: UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs | TryAddValuesAsync_UpdatesExistingValue 236-249; only invocation 244 +CITATION: UtilitiesCS.Test/UtilitiesCS.Test.csproj | TestHelpers items 74-77; DfDeedle items 189-192; OlTableExtensions_Tests item 541; line count 985 +CITATION: UtilitiesCS.Test/Properties/AssemblyInfo.cs | Parallelize(Workers = 0, Scope = ClassLevel) at 18-21 +CITATION: .github/workflows/_mstest-coverage.yml | recursive discovery 86-92; invocation 99 +CITATION: scripts/vscode/Invoke-MSTestWithCoverage.ps1 | argument shape 70-76; discovery and dot-claude rejection 296-307 +CITATION: docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/spec.md | Acceptance Criteria 291-296; Files/modules to change 151-182 +CITATION: docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/research/root-cause.2026-09-07T22-10.md | Sections 1 to 5 +AC-INVENTORY: AC1 AC2 AC3 AC4 AC5 +AC-MAPPING: AC1 | IMPLEMENTATION: P4-T1 | TESTS: P4-T2,P4-T4,P8-T1 | EVIDENCE: fail-before-exception dossier (P4-T3), p4-t4-ac1-pass-after.md, p8-t6-ac4-ten-run.md, check-off P4-T5 +AC-MAPPING: AC2 | IMPLEMENTATION: P1-T1,P1-T2,P1-T3,P1-T4,P3-T1 | TESTS: P1-T5,P2-T1,P2-T4,P3-T2 | EVIDENCE: p1-t9-seam-scoped-run.md, p2-t4-ac2-fail-before.md, p3-t2-ac2-pass-after.md, check-off P3-T3 +AC-MAPPING: AC3 | IMPLEMENTATION: P5-T1,P5-T2,P5-T3,P5-T4 | TESTS: P5-T5,P5-T6,P5-T7,P5-T8,P5-T9,P5-T10 | EVIDENCE: p5-t10-ac3-pass-after.md, check-off P5-T11 +AC-MAPPING: AC4 | IMPLEMENTATION: P0-T12,P7-T12 | TESTS: P8-T1,P8-T2,P8-T3,P8-T4,P8-T5 | EVIDENCE: p8-t1..p8-t5-ac4-runs.md, p8-t6-ac4-ten-run.md, check-off P8-T7 +AC-MAPPING: AC5 | IMPLEMENTATION: P6-T1,P6-T3 | TESTS: P6-T5,P7-T10 | EVIDENCE: p6-t5-etl-clock-pass.md, p7-t10-ac5-timing-hack-search.md, check-off P7-T11 +UNRESOLVED-GAPS: NONE diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/policy-audit.2026-09-08T11-30.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/policy-audit.2026-09-08T11-30.md new file mode 100644 index 000000000..82fda2438 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/policy-audit.2026-09-08T11-30.md @@ -0,0 +1,493 @@ +# Policy Audit — utilitiescs-test-determinism (Issue #811) + +- Component: `UtilitiesCS`, `UtilitiesCS.Test` +- Date: 2026-09-08T11-30 +- Branch: `bug/utilitiescs-test-determinism-780-803-594-811` +- Head commit: `3805ca89f0c72e2bb448726695f18f420bd260f7` +- Base commit (merge-base anchor): `bb1c7d4b60f7b782227956f36859314d5c47bb03` +- Work mode: `full-bug` (`issue.md` line 12) — acceptance-criteria source is `spec.md` only +- Reviewer: feature-review agent + +## Executive Summary + +Verdict: **PASS with one blocking process finding.** + +The change repairs three distinct nondeterminism defects in `UtilitiesCS.Test` and its production +dependencies: a consumer-free 500 ms wall-clock cancellation window in +`DictionaryExtensions.TryAddValuesAsync`; an unguarded null-snapshot dereference on the +`DfDeedle.GetEmailDataInViewAsync` ETL path together with two mutable process-wide ETL delegate +statics; and four `Console.Out` capture races. All four gates of the mandatory toolchain pass on +the final tree, independently corroborated from the raw MSBuild logs in this worktree. Repository +line coverage rose and no changed file regressed. + +Four of five acceptance criteria are met. AC4 (zero failures across ten consecutive full-suite +runs) is not met: run 7 of 10 reported one failure in +`MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions`. This reviewer independently +confirmed the cause is an unsynchronised process-wide static in +`UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs`, which this change does not touch, and +confirmed that the failure signature is uniquely explained by that mechanism (see section 8, F-1). +The AC4 shortfall is graded Non-blocking for merge. + +One finding is graded **Blocking**: the `ILGlobals` race — the sole reason an acceptance criterion +is unmet — has no durable follow-up artifact. Two other follow-ups were filed as +`docs/features/potential/` entries by the same plan task, so the executor's stated reason for +omitting the third ("authoring it was not in this plan's write set") is contradicted by its own +write set. Without a durable entry the finding is lost at merge. + +Blocking findings in this artifact: **1**. + +## Scope Confirmation + +The audit scope is the full branch diff against `bb1c7d4b`. The caller supplied two pre-generated +read-only artifacts because `git` invocation from a review agent hangs in this checkout: + +- `coverage/review-811-source.patch` — unified diff of the 20 changed `.cs` / `.csproj` files +- `coverage/review-811-diffstat.txt` — diffstat of all 68 changed files + +The diffstat confirms the full change set is 68 files: 20 source/project files, 46 feature-folder +documents and evidence artifacts, and 2 `docs/features/potential/` entries. The source patch is a +projection of the source subset, not a scope narrowing: the remaining 48 files were audited by +reading them directly. No caller instruction attempted to narrow the audit to a plan, task, phase, +or file subset, to mark a language as not applicable when it has changed files, or to skip a +toolchain or coverage check. No `## Rejected Scope Narrowing` section is therefore required. + +Changed languages in the branch diff: **C# only**. No `.ps1`, `.py`, `.ts`, `.tsx`, `.yml` or +`.yaml` file appears in the diffstat. + +## Evidence Location Compliance + +All evidence artifacts this change produced are under +`docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence//` +(`baseline`, `qa-gates`, `regression-testing`, `issue-updates`, `other`). The diffstat contains no +path under `artifacts/baselines/`, `artifacts/qa/`, `artifacts/evidence/` or `artifacts/coverage/`. + +| Check | Result | +|---|---| +| Files written to `artifacts/baselines/` | 0 | +| Files written to `artifacts/qa/` | 0 | +| Files written to `artifacts/evidence/` | 0 | +| Files written to `artifacts/coverage/` | 0 | +| Evidence under `/evidence//` | 33 files across 5 canonical kinds | + +Verdict: **PASS**. + +Working files produced during execution (TRX, Cobertura XML, MSBuild logs, the review patch) live +under the gitignored `coverage/` directory and do not appear in the branch diff. That is correct: +`coverage/` is a scratch location, not an evidence location, and nothing there is cited as +committed evidence except by value transcribed into a committed `/evidence/` artifact. + +## 1. General Unit Test Policy Compliance + +Reference: `.claude/rules/general-unit-test.md`. + +| Requirement | Verdict | Evidence | +|---|---|---| +| Independence — tests run in any order | PASS | Three `[DoNotParallelize]` attributes removed only after the shared-state dependency was eliminated by a `TextWriter` parameter; the two new classes carry `[DoNotParallelize]` and document why. | +| Isolation — one unit of behavior per test | PARTIAL | One exception: `OlTableExtensions_Tests.EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart` performs two Acts (seam path and null-writer path). See section 8, F-3. | +| Fast execution | PASS | Full nine-assembly suite 51.3 s to 73.9 s over ten runs; the new deadline tests are clock-driven, not wall-clock. | +| Determinism | PASS | Every new deadline test drives `FakeTimeProvider` or `ArmingBarrierTimeProvider`. No test advances wall time. | +| Readability / maintainability | PASS | Every new test method and helper carries an XML doc comment stating the scenario and the reason for the technique. | +| Line coverage >= 85% | PASS | Repository line-rate 86.0424% on the final Cobertura document, re-derived by this reviewer from the `` root element. | +| Branch coverage >= 75% | FAIL | Repository branch-rate 66.3978%. Pre-existing: baseline branch-rate was 66.3058%. This change improved it by +0.092 percentage points. See section 8, F-2. | +| No regression on changed lines | PASS | All 38 added executable lines report `hits > 0`; no per-file uncovered count rose across the eight production files. | +| No coverage exclusion of production paths | PASS | No `exclude` entry and no `[ExcludeFromCodeCoverage]` attribute is added by this change. | +| Scenario completeness (positive, negative, edge, error) | PASS | Positive: green-clock paths on both new classes. Negative/error: deadline expiry throwing `InvalidOperationException`, pre-cancelled token throwing `TaskCanceledException`. Edge: null `TextWriter` and null `TimeProvider` defaults both exercised. | +| Arrange–Act–Assert structure | PASS | All new tests carry explicit `// Arrange` / `// Act` / `// Assert` markers. | +| No external dependencies | PASS | Every boundary is a Moq mock of an Outlook COM interface. No live Outlook, network, or database. | +| No temporary files in tests | PASS | Zero filesystem writes in the added test code; assertions target in-memory `StringWriter` instances. | +| Test files mirror source tree | PASS | `UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs`, `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` and `UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs` all sit under the test project mirroring their production counterparts. No test file was placed in a production source tree. | +| Banned APIs in test code (`Thread.Sleep`, `Task.Delay`, timed waits, wall-clock reads) | PASS | Zero occurrences across the 737 added lines; independently confirmed against the diff. The three surviving `gate.Wait` references are untimed `ManualResetEventSlim.Wait()` method groups passed as delegates and released in `finally`. | +| Fake timers / `FakeTimeProvider` for async time | PASS | `Microsoft.Extensions.Time.Testing.FakeTimeProvider` is used in five tests; `ArmingBarrierTimeProvider` wraps it to enforce arm-before-advance ordering. | + +## 2. General Code Change Policy Compliance + +Reference: `.claude/rules/general-code-change.md` and CLAUDE.md. + +| Requirement | Verdict | Evidence | +|---|---|---| +| Simplicity first | PASS | The three defects share one technique (make the wall clock or the writer injectable, or delete it when it guards nothing). No new abstraction layer is introduced. | +| Reusability | PASS | `ArmingBarrierTimeProvider` was moved out of `DfDeedleQfcColumnTimeoutTests` into `UtilitiesCS.Test/TestHelpers/` and is now shared by three classes rather than duplicated. | +| Extensibility / no breaking API change | PASS | Every new parameter is optional and trailing. Two `internal static` fields were removed; all readers and writers were in-repo and are updated in the same change. | +| Separation of concerns | PASS | The `TextWriter` seam separates rendering from the process-wide console; the `TimeProvider` seam separates deadline policy from the system clock. | +| Bugfix workflow — failing regression test first | PASS | `evidence/regression-testing/p2-t4-ac2-fail-before.md` and `fail-before-exception.2026-09-08T09-50.md` record the RED state for AC2; `p3-t2-ac2-pass-after.md` records GREEN after the fix. AC1's RED artifact is documented as a contract-shape test rather than a load reproduction, with the reason stated in `spec.md` (a load-dependent failure cannot be made deterministically RED without a sleep, which AC5 forbids). | +| Bugfix workflow — minimal targeted fix | PASS | Production edits are confined to eight files; each is a deletion of a consumer-free guard, an optional parameter, or a new guard clause. | +| Bugfix workflow — open a new issue rather than widen scope | PARTIAL | Two of three uncovered defects were filed as `docs/features/potential/` entries. The third (`ILGlobals`) was not. See section 8, F-1. | +| Full toolchain in order, restart on change | PASS | `Toolchain pass: 3` is recorded on all four P7 gate artifacts, meaning the final clean pass followed two earlier passes that changed files. | +| File size limit 500 lines | PASS | See section 7. No new violation; all three pre-existing violations held or shrank. | +| Fail fast and explicitly | PASS | The new `InvalidOperationException` in `DfDeedle.GetEmailDataInViewAsync` names the folder and the failure mode, replacing an unattributed `NullReferenceException` thrown one statement later. | +| Established logging pattern | PASS | The corrected `EtlAsync` timeout log keeps the existing `logger.Error` call and removes a retry count that was never consumed plus a banned `DateTime.Now` format call. | +| Naming | PASS | `PascalCase` types and public members, `camelCase` locals and parameters throughout the added code. | +| Comment why, not what | PASS | Comments on `timeProvider` parameters state the production-invariance rationale; the retained `[DoNotParallelize]` on `OlTableExtensions_Tests` states the new reason after the console reason ceased to apply. | +| No new dependencies | PASS | `Microsoft.Bcl.TimeProvider` and `Microsoft.Extensions.TimeProvider.Testing` were already referenced. The only project-file change is three `` items. | +| I/O boundaries isolated | PASS | `Console.Out` moves from an implicit process-wide dependency to an explicit parameter on four production members. | + +## 3. Language-Specific Code Change Policy Compliance (C#) + +Reference: `.claude/rules/csharp.md` and CLAUDE.md sections C#1 to C#7. + +| Requirement | Verdict | Evidence | +|---|---|---| +| CSharpier formatting, via `dotnet tool run` | PASS | `dotnet tool run csharpier check .` exit 0, `Checked 1616 files in 7454ms.` The file count rose by exactly 3, matching the three new `.cs` files. | +| Analyzer gate with `/t:Rebuild` | PASS | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` exit 0. Independently corroborated: `coverage/msbuild-p7-t3.log` lines 11388-11389 read `0 Warning(s)` and `0 Error(s)`. | +| Nullable / warnings-as-errors gate with `/t:Rebuild` | PASS | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` exit 0. Independently corroborated: `coverage/msbuild-p7-t4.log` lines 11312-11313 read `0 Warning(s)` and `0 Error(s)`. `/p:Nullable=enable` is correctly not supplied, matching `.github/workflows/_build-nullable.yml`. | +| `/t:Rebuild` rather than `/t:Build` | PASS | Both gate commands are recorded verbatim with `/t:Rebuild`, so `CoreCompile` ran and neither gate is vacuous. | +| Nullable annotations on touched production files | PASS | All eight production files carry `#nullable enable`; the new `TimeProvider?`, `TextWriter?` and `Func<...>?` parameters and the `is null` guard produced no `CS86xx` diagnostic under warnings-as-errors. | +| Strong contracts / explicit public API | PASS | The removed `TableEtlInvoker` / `StoreTableEtlInvoker` mutable statics are replaced by an explicit optional `etl` parameter; the surviving default is `private static readonly DefaultTableEtl`, which narrows the public surface. | +| Composition over inheritance | PASS | `ArmingBarrierTimeProvider` derives from `TimeProvider` because that is the framework extension point, and it forwards every member to a composed `FakeTimeProvider` rather than reimplementing behavior. | +| Exceptions — fail fast, no broad catch added | PASS | No new `catch (Exception)` is introduced. The pre-existing `catch (TimeoutException)` in `EtlAsync` is retained deliberately and is now covered by a test that documents the swallow-and-cancel contract. | +| Public surface minimal, `internal` preferred | PASS | `ArmingBarrierTimeProvider` is `internal sealed`. `GFG.Run(TextWriter)` is `public` because `GFG.Main` already was and the test asserts on it. | +| XML docs where behavior is non-obvious | PASS | Both new test classes, the shared helper, and `GFG.Run` carry doc comments or explanatory comments. | +| DI seam preference order | PASS | Injectable delegate (`Func)>? etl`) is preference 2 and is correct for a single call path; `TimeProvider` is the explicitly named time seam. Constructor injection is not available because all touched members are `static`, so a trailing optional parameter is the smallest viable seam. | +| Banned symbols posture | PASS (improved) | One `DateTime.Now` call was removed from the `EtlAsync` timeout log line. No banned symbol is added. | +| No broad refactor across unrelated projects | PASS | Two projects touched, both named in `spec.md` item list 1-20. The write set matches the spec exactly. | +| No weakened assertions | PASS | The retired `Returns(120)` row-count tolerance is replaced by an un-advanced `FakeTimeProvider`, which constrains the deadline rather than widening it. Assertions on the affected test are unchanged. | + +## 4. Language-Specific Unit Test Policy Compliance (C#) + +| Requirement | Verdict | Evidence | +|---|---|---| +| MSTest as the framework | PASS | `Microsoft.VisualStudio.TestTools.UnitTesting` with `[TestClass]` / `[TestMethod]` in all new and modified test files. No xUnit or NUnit reference is introduced. | +| Moq for mocking | PASS | `Mock`, `Mock`, `Mock`, `Mock`, `Mock`, `Mock` throughout the two new classes, with `MockBehavior.Strict` where the interaction set is closed. | +| FluentAssertions for assertions | PASS | `Should().Be`, `Should().BeNull`, `Should().Contain`, `FluentActions.Awaiting(...).Should().ThrowAsync().WithMessage("*Inbox*")`, `FluentActions.Invoking(...).Should().NotThrow()`. No MSTest `Assert` call is added. | +| `[TestClass]` / `[TestMethod]` attributes | PASS | Present on every added class and method. | +| Arrange–Act–Assert | PASS | Explicit section comments in all added tests. | +| No external dependencies | PASS | No live COM, no network, no filesystem. | +| Repo-wide line coverage >= 80% (CLAUDE.md UT2) | PASS | 86.0424%. | +| New module/class/method >= 90% (CLAUDE.md UT2) | PASS | `StackGeek.GFG.Run` line rate 1.0, measured by method element and reported as a number rather than absent, so the measurement is not vacuous. | +| Coverage regression on changed lines is blocking | PASS | Zero uncovered added executable lines; zero per-file regressions. | +| Deterministic test rules (no PATH, profile, cwd, clock dependence) | PASS | Clocks are injected; no ambient state is read. | + +## 5. Test Coverage Detail + +Coverage was verified from pre-existing artifacts. This reviewer re-derived the repository figures +directly from the `` root elements of `coverage/p0-baseline.cobertura.xml` and +`coverage/p7-final.cobertura.xml` rather than accepting the transcribed values. + +| Language | Metric | Value | Threshold | Verdict | +|---|---|---|---|---| +| C# | repository line coverage | 86.0424% (172626 / 200629) | >= 85% | PASS | +| C# | repository branch coverage | 66.3978% (21489 / 32364) | >= 75% | FAIL | +| C# | new-file line coverage | all added executable lines covered (38 / 38) | >= 85% new-code floor | PASS | +| C# | modified-file line coverage, no regression | 0 of 8 production files regressed | no regression | PASS | +| PowerShell | coverage | zero changed `.ps1` files in the branch diff | not evaluated | N/A | +| Python | coverage | zero changed `.py` files in the branch diff | not evaluated | N/A | +| TypeScript | coverage | zero changed `.ts` / `.tsx` files in the branch diff | not evaluated | N/A | + +Repository-wide movement, baseline to final: + +| Metric | Baseline | Final | Delta | +|---|---|---|---| +| `line-rate` | 0.8601092896174863 | 0.8604239666249645 | +0.0003147 | +| `lines-valid` | 200385 | 200629 | +244 (0.1218%) | +| `branch-rate` | 0.6630576006929406 | 0.6639784946236559 | +0.0009209 | +| `branches-valid` | 32326 | 32364 | +38 | + +The denominator moved 0.1218%, inside the 1% comparability band, so the rate comparison is +directly meaningful (comparability branch A). Both rates improved. + +Per-file uncovered line counts, baseline to final: + +| Production file | Baseline uncovered | Final uncovered | Verdict | +|---|---|---|---| +| `UtilitiesCS/Extensions/DictionaryExtensions.cs` | 7 | 7 | PASS | +| `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` | 26 | 12 | PASS (improved) | +| `UtilitiesCS/Extensions/DfDeedle.cs` | 2 | 0 | PASS (improved) | +| `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs` | 52 | 52 | PASS | +| `UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs` | 3 | 3 | PASS | +| `UtilitiesCS/HelperClasses/PrettyPrint.cs` | 65 | 65 | PASS | +| `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs` | 55 | 55 | PASS | +| `UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs` | 0 | 0 | PASS | + +`EtlAsync` method-span line rate rose from 0.7000 to 0.9796. The previously uncovered +`catch (TimeoutException)` block is now exercised by +`OlTableExtensionsEtlClockTests.EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource`. + +Coverage-artifact path note: the canonical path `artifacts/csharp/coverage.xml` does not exist in +this worktree. Verification was performed against `coverage/p7-final.cobertura.xml` (a valid +Cobertura document with a `` root, read directly by this reviewer) plus the committed +transcriptions in `/evidence/qa-gates/p7-t5-vstest-coverage.md` and `p7-t6-coverage-delta.md`. +The substance of the coverage gate is verified; only the canonical file location is absent. This is +recorded as a procedural deviation, not a coverage failure. See section 8, F-5. + +## 6. Test Execution Metrics + +| Metric | Value | +|---|---| +| Assemblies in the gate | 9 | +| Tests per run | 7162 | +| Runs executed for AC4 | 10 | +| Runs with zero failures | 9 | +| Runs with failures | 1 (run 7: 7161 passed, 1 failed) | +| Wall clock per run | 51.3 s to 73.9 s | +| Class-level parallel workers | 24 (`Workers = 0` resolving to `ProcessorCount`) | +| Tracked-test assertions across the ten runs | 130 (13 tests x 10 runs), all `Passed` | +| Source commit under test for all ten runs | `03b7bd57cacfc902a8b9f4e917ace627ffcac464`, identical across all five pair tasks | +| Tree dirty during the runs | No (`git status --porcelain -- "*.cs" "*.csproj"` entry count 0) | + +Filter deviation, restated: the local runs extend `TestCategory!=LiveOutlook` with +`FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser`. +That exclusion is a documented workstation-environmental issue carried forward from the #798 +baseline, not a property of this change, and CI runs those classes unfiltered. Ten local filtered +runs and one unfiltered CI run are not equivalent to ten unfiltered runs; the evidence artifact +states both facts rather than conflating them, which this reviewer confirms is the honest framing. + +## 7. Code Quality Checks + +| Check | Command | Result | +|---|---|---| +| Format (csharpier check) | `dotnet tool run csharpier check .` | PASS — exit 0, 1616 files checked | +| Lint (.NET analyzers) | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` | PASS — exit 0, 0 warnings, 0 errors | +| Type check (nullable, warnings as errors) | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` | PASS — exit 0, 0 warnings, 0 errors | +| Test | `vstest.console.exe /EnableCodeCoverage /InIsolation` | PARTIAL — 9 of 10 runs clean; run 7 failed one test outside the write set | + +File size cap (500 lines), verified against the plan's requirement that no pre-existing violation +grow: + +| File | Baseline | Final | Verdict | +|---|---|---|---| +| `UtilitiesCS/HelperClasses/PrettyPrint.cs` | 680 | 680 | PASS — held exactly, pre-existing violation not enlarged | +| `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` | 869 | 848 | PASS — reduced by 21, pre-existing violation | +| `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` | 1855 | 1846 | PASS — reduced by 9, pre-existing violation | +| `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` | 500 | 453 | PASS — helper extracted to `TestHelpers/` | +| `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` | 475 | 476 | PASS — under cap | +| Largest new file | n/a | `OlTableExtensionsEtlClockTests.cs` at 234 | PASS | +| `UtilitiesCS/Threading/TimeOutTask.cs` | 1011 | 1011 | PASS — untouched pre-existing violation | + +`PrettyPrint.cs` held at exactly 680 because three unused `using` directives +(`System.Threading.Tasks`, a duplicated inner `System.Text`, and `Svg`) were removed to pay for the +two added `TextWriter?` parameter lines and one added `System.IO` directive. That is an incidental +cleanup, but it is confined to the same file and is the mechanism by which a pre-existing violation +was prevented from growing. It compiles clean under the analyzer and nullable gates, which +establishes the removed directives were genuinely unused. + +Three pre-existing 500-line violations are correctly reported as pre-existing, not as newly +introduced. `UtilitiesCS.Test/UtilitiesCS.Test.csproj` at 988 lines is not subject to the cap: the +General Code Change Policy applies it to production code, test code and reusable script files. + +## 8. Gaps and Exceptions + +### F-1 — BLOCKING — The `ILGlobals` static race has no durable follow-up artifact + +The AC4 ten-run gate failed on run 7 with +`UtilitiesCS.Test.NewtonsoftHelpers.SDILReader.MethodBodyReader_Tests.GetBodyCode_ReturnsConcatenatedInstructions`. +This reviewer verified the mechanism independently by reading the source rather than accepting the +executor's derivation: + +- `UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs:123` reassigns `singleByteOpCodes` to a + freshly allocated all-default `OpCode[0x100]`, then fills it in a reflection loop at line 137. + There is no lock, no `Lazy`, and no `volatile`. Lines 117-118 declare both tables as plain + mutable `public static` fields. +- `UtilitiesCS/NewtonsoftHelpers/SDIL Reader/MethodBodyReader.cs:105` reads + `ILGlobals.singleByteOpCodes[(int)value]` with no synchronisation. +- Exactly two test classes call `LoadOpCodes()`: `ILGlobals_Tests` (lines 15, 26, 37, 47) and + `MethodBodyReader_Tests` (line 364, inside the private `CreateReader` helper that every test in + that class routes through). Neither class carries `[DoNotParallelize]`, and both are therefore + parallel-eligible under `[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)]` + at `UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21`. +- Within a class, `ClassLevel` scope serialises the tests, so `MethodBodyReader_Tests` cannot race + itself. The second participant must be `ILGlobals_Tests`. + +The observed failure output corroborates the mechanism uniquely. The reported body was: + +``` +0000 : nop +0001 : 1879067923 +0006 : stloc.0 +0007 : br.s 0009 +0009 : ldloc.0 +0010 : ret +``` + +Only the entry at IL offset 0001 — `ldstr`, opcode `0x72` — degraded, while `nop` (0x00), +`stloc.0` (0x0A), `br.s` (0x2B), `ldloc.0` (0x06) and `ret` (0x2A) all resolved correctly. That is +the signature of a partially filled table, not of a null or wholly-uninitialised one. The two +spaces where the opcode name belongs are the empty `Name` of `default(OpCode)`; +`default(OpCode).OperandType` is `InlineBrTarget` (value 0), which drives the reader down the +numeric-operand branch and stores a raw metadata token instead of calling +`module.ResolveString(...)`. The printed value 1879067923 lies in the `0x70……` range, which is the +String metadata-token range, so the token was read correctly and merely not resolved. No other +mechanism explains a single-entry degradation with a correctly-ranged unresolved string token. + +The defect is pre-existing and outside this item's 20-path write set. That is not the finding. The +finding is that it has no durable record. The executor filed two follow-ups as +`docs/features/potential/` entries in task P8-T9 +(`2026-09-08-etl-deadline-mechanics-follow-ups.md` and +`2026-09-08-console-out-aggressors-and-banned-symbol-promotion.md`) and confirmed both in P8-T12, +but wrote for the third: "it is reported to the orchestrator rather than filed here, because +authoring it was not in this plan's write set" +(`evidence/issue-updates/issue-811.2026-09-08T10-44.md` lines 77-78). That justification is +contradicted by the same plan's own conduct: `docs/features/potential/` demonstrably was in the +write set, because two entries were written there. This reviewer confirmed by search that no file +under `docs/features/potential/` mentions `ILGlobals`, `SDIL` or `MethodBodyReader`. + +Impact: the sole reason an acceptance criterion is unmet exists only as prose inside a feature +folder that is archived at merge. The `mstest-coverage` required check will continue to fail +intermittently on unrelated pull requests through this mechanism, and the diagnosis will have to be +redone from scratch. + +Required remediation: author +`docs/features/potential/2026-09-08-ilglobals-loadopcodes-unsynchronised-static-race.md` recording +the mechanism, the two racing classes, the observed signature, and the candidate fixes (a `lock` +around `LoadOpCodes`, a `Lazy` pair, static-constructor initialisation, or +`[DoNotParallelize]` on both classes as an interim stopgap), then promote it to a GitHub issue. + +### F-2 — Non-blocking — Repository branch coverage is below the 75% floor + +Repository branch-rate is 66.3978% against the >= 75% uniform threshold in +`.claude/rules/quality-tiers.md`. Recorded as **FAIL** because the rule states a hard floor and no +maintainer exemption is recorded in `issue.md` or `spec.md`. + +Disposition: **non-blocking, no remediation demanded of this change.** The shortfall is +pre-existing (baseline 66.3058%), this change improved it by +0.092 percentage points, and closing +an 8.6-point repository-wide branch gap is not achievable inside a three-defect determinism bugfix. +Note also the unreconciled documentation conflict already known in this repository: CLAUDE.md UT2 +states an 80% line floor with no branch floor, while `.claude/rules/general-unit-test.md` and +`.claude/rules/quality-tiers.md` state 85% line and 75% branch. The change clears the CLAUDE.md +floor and the rules-file line floor; only the rules-file branch floor is missed, and only +repository-wide. + +### F-3 — Non-blocking — One test performs two Acts + +`OlTableExtensions_Tests.EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart` +asserts on the supplied-writer path and then invokes the null-writer path in the same method, with +the in-code justification "this file is at its line ceiling". This is a documented, deliberate +trade against `.claude/rules/general-unit-test.md` isolation. It is acceptable given the +1846-line pre-existing violation the plan was required not to enlarge, but the cleaner resolution +is a small separate test file, which would also reduce the offending file. Recorded for follow-up, +not for remediation now. + +### F-4 — Non-blocking — `MessageBoxInvoker` remains a mutated process-wide static + +AC2's first clause is "`DfDeedle_COM_Tests` no longer mutates process-wide static seams in a way +another class can observe". The two ETL delegate statics are gone, but `DfDeedle.MessageBoxInvoker` +survives and is still swapped by four tests in `DfDeedle_COM_Tests` (lines 200-213, 225-238, +264-283, 320-336), each restoring in `finally`. `spec.md` settles this deliberately, and this +reviewer verified the exposure argument independently: the only production readers are +`DfDeedle.QfcColumns.cs:27,170,194`, and only three test classes reach those paths — +`DfDeedle_COM_Tests` itself (whose tests are serialised by `ClassLevel` scope), +`DfDeedleQfcColumnTimeoutTests` and `DfDeedleEtlTimeoutTests`, both `[DoNotParallelize]`. The +residual exposure therefore depends on the same MSTest ordering assumption that `spec.md` Risk 3 +flags as documentation-sourced and not run-verified — the assumption AC3's seam work was +specifically intended to stop relying on. It is an unenforced invariant rather than an observed +defect; no test or analyzer prevents a future edit from removing `[DoNotParallelize]` from either +reader class. + +### F-5 — Non-blocking — Canonical coverage artifact path and PR context artifacts absent + +`artifacts/csharp/coverage.xml` does not exist; `artifacts/pr_context.summary.txt` and +`artifacts/pr_context.appendix.txt` do not exist. Under this reviewer's standing procedure the +absence of a canonical coverage artifact for a changed language is a FAIL, so it is recorded as +one, with a **non-blocking** disposition: the underlying Cobertura document +(`coverage/p7-final.cobertura.xml`) exists and was read directly by this reviewer, so every +coverage figure in section 5 is independently verified rather than merely transcribed. PR context +artifacts could not be regenerated because `git` invocation from a review agent hangs in this +checkout; the caller pre-generated an equivalent unified diff and a full 68-file diffstat, which +were used instead. Both deviations are procedural. + +### F-6 — Non-blocking — AC4 literal condition not met + +Recorded here for completeness; the substantive analysis is in `feature-audit.2026-09-08T11-30.md`. +AC4 requires zero failures on ten consecutive runs and 9 of 10 were clean. The criterion is +correctly left unchecked in `spec.md` and correctly reported as "not delivered" in the issue-update +artifact. No re-run-until-green was attempted, which is the correct discipline for a gate whose +entire purpose is to detect intermittency. This reviewer does not require the AC to be forced +green. + +## 9. Summary of Changes + +68 files changed, 4177 insertions, 351 deletions. + +Production (`UtilitiesCS`, 8 files): + +1. `Extensions/DictionaryExtensions.cs` — deleted the linked `CancellationTokenSource` and + `CancelAfter(500)` from `TryAddValuesAsync`; the caller token now flows directly to `Task.Run`. + Verified: `TryAddValuesAsync` has zero production call sites (the only invocations anywhere are + two test methods), so the deletion cannot change production behavior. It also removes a per-call + undisposed timer allocation. +2. `Extensions/DfDeedle.cs` — added trailing `TimeProvider? timeProvider = null` to + `GetEmailDataInViewAsync` and forwarded it to `AddQfcColumnsAsync`, `EtlAsync` and the 1000 ms + dataframe-transform deadline; added a null-snapshot guard throwing `InvalidOperationException` + ahead of the `LogDfTiming` dereference; replaced the mutable `TableEtlInvoker` static with an + optional `etl` parameter and a `private static readonly DefaultTableEtl`. +3. `Extensions/DfDeedle.FrameUtilities.cs` — replaced `StoreTableEtlInvoker` with an optional `etl` + parameter on both `FromDefaultFolder` overloads, the `Stores` overload forwarding to the `Store` + overload. +4. `OutlookObjects/Table/OlTableExtensions.Etl.cs` — added trailing `TimeProvider? timeProvider` + to `EtlAsync` and `EtlByRowAsync`; converted three `TimeoutAfter(ms, attempts)` calls to + `TimeoutAfter(ms, timeProvider)`; deleted the inert `attempts` local; corrected the timeout log + text and removed its `DateTime.Now`. +5. `OutlookObjects/Filter DASL/DASLFilterParser.cs` — `PrintTree` gained `TextWriter? writer = null`, + forwarded on recursion. +6. `HelperClasses/PrettyPrint.cs` — both `PrettyPrint` overloads gained `TextWriter? writer = null` + at zero net line growth. +7. `OutlookObjects/Table/OlTableExtensions.TableAccess.cs` — `EnumerateTable` gained + `TextWriter? writer = null` routing its three writes. +8. `ReusableTypeClasses/Other/StackGeek.cs` — extracted `GFG.Main`'s body into + `public static void Run(TextWriter)`; `Main` now calls `Run(Console.Out)`. + +Tests (`UtilitiesCS.Test`, 11 files) and one project file: three new files +(`DfDeedleEtlTimeoutTests.cs`, `OlTableExtensionsEtlClockTests.cs`, +`TestHelpers/ArmingBarrierTimeProvider.cs`), the `ArmingBarrierTimeProvider` move out of +`DfDeedleQfcColumnTimeoutTests.cs`, four capture-and-assert tests converted to test-owned writers, +`[DoNotParallelize]` removed from three classes and retained with a corrected rationale on the +fourth, the `NLogTraceWriter_Test` console save/restore removed, the `Returns(120)` timing +tolerance retired, and three `` items added. + +Documentation: 46 feature-folder and evidence artifacts, plus two `docs/features/potential/` +follow-up entries. + +## 10. Compliance Verdict + +| Policy | Verdict | +|---|---| +| CLAUDE.md standing instructions | PASS | +| `.claude/rules/general-code-change.md` | PASS | +| `.claude/rules/general-unit-test.md` | PARTIAL — repository branch coverage below floor (pre-existing, improved); one test with two Acts | +| `.claude/rules/csharp.md` | PASS | +| `.claude/rules/quality-tiers.md` | PARTIAL — line floor met, branch floor missed repository-wide (pre-existing, improved) | +| `.claude/rules/tonality.md` | PASS — all authored prose in the feature folder is factual, measured, and free of humor, hyperbole and decorative metaphor; deviations are stated plainly, including the sections the executor titled "stated honestly" | +| Evidence location invariant | PASS | +| Scope invariant (full branch diff vs base) | PASS | + +**Overall: PASS with one blocking process finding (F-1).** + +Blocking findings in this artifact: **1**. + +## Appendix A: Test Inventory + +New test classes and methods: + +| File | Method | Purpose | +|---|---|---| +| `Extensions/DfDeedleEtlTimeoutTests.cs` | `GetEmailDataInViewAsync_EtlDeadlineExpires_ThrowsInvalidOperationNamingFolder` | RED-first AC2 regression: deadline expiry must produce a folder-naming `InvalidOperationException`, not an `NullReferenceException` | +| `Extensions/DfDeedleEtlTimeoutTests.cs` | `GetEmailDataInViewAsync_ClockNeverAdvances_ReturnsOneRowFrame` | Deterministic green path on an un-advanced clock | +| `Extensions/DfDeedleEtlTimeoutTests.cs` | `GetEmailDataInView_NoEtlArgument_UsesProductionDefaultDelegate` | Covers the `etl ?? DefaultTableEtl` default branch left by the removed static | +| `OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` | `EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource` | Documents the surviving swallow-and-cancel contract; covers the previously uncovered `catch (TimeoutException)` | +| `OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` | `EtlAsync_ClockNeverAdvances_ReturnsTransformedRows` | Deterministic green row path | +| `OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` | `EtlAsync_NoBinaryOrObjectFields_UsesGetArrayBranchOnControlledClock` | Covers the `GetArray` branch's `TimeoutAfter` | +| `Extensions/DictionaryExtensions_Tests.cs` | `TryAddValuesAsync_PreCancelledToken_ThrowsTaskCanceledAndLeavesValueUnchanged` | Locks the surviving AC1 cancellation contract | +| `HelperClasses/PrettyPrint_Tests.cs` | `PrettyPrint_NullWriter_WritesToConsoleWithoutThrowing` | Null-writer default of both overloads | +| `ReusableTypeClasses/StackGeek_Tests.cs` | `Run_WritesScenarioToSuppliedWriter` | The `TextWriter` seam, asserting on a test-owned writer | + +Renamed or converted tests: `DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput` to +`...AndWriterOutput`; `PrintTree_WritesIndentedTreeToConsole` to `...ToSuppliedWriter`; +`EnumerateTable_WritesFormattedOutputAndMovesToStart` to `...ToSuppliedWriterAndMovesToStart`; +`Main_RunsSampleScenarioWithoutThrowing` retained and narrowed to the null-writer default. + +New shared helper: `TestHelpers/ArmingBarrierTimeProvider.cs`, `internal sealed`, moved out of +`DfDeedleQfcColumnTimeoutTests` rather than duplicated. + +## Appendix B: Toolchain Commands Reference + +``` +dotnet tool run csharpier format . +dotnet tool run csharpier check . +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +vstest.console.exe /EnableCodeCoverage /InIsolation "/TestCaseFilter:TestCategory!=LiveOutlook" +``` + +Template note: the MCP asset `mcp__drm-copilot__resolve_policy_audit_template_asset` is not +available in this agent session, so this artifact was hand-authored preserving the twelve canonical +major headings required by `.claude/skills/policy-audit-template-usage/SKILL.md`. The MCP validator +`mcp__drm-copilot__validate_orchestration_artifacts` is likewise unavailable and was not run. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/remediation-inputs.2026-09-08T11-30.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/remediation-inputs.2026-09-08T11-30.md new file mode 100644 index 000000000..41e0f9d23 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/remediation-inputs.2026-09-08T11-30.md @@ -0,0 +1,103 @@ +# Remediation Inputs — utilitiescs-test-determinism (Issue #811) + +- Date: 2026-09-08T11-30 +- Branch: `bug/utilitiescs-test-determinism-780-803-594-811` +- Head: `3805ca89f0c72e2bb448726695f18f420bd260f7` +- Base: `bb1c7d4b60f7b782227956f36859314d5c47bb03` + +## Source Artifacts + +- `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/policy-audit.2026-09-08T11-30.md` +- `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/code-review.2026-09-08T11-30.md` +- `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/feature-audit.2026-09-08T11-30.md` + +## Blocking Findings — Remediation Required + +Count: **1**. + +### R-1 (from policy-audit F-1) — File and promote the `ILGlobals` unsynchronised-static race + +**Why it is blocking.** This defect is the sole reason acceptance criterion AC4 is unmet. It +currently exists only as prose inside `/evidence/`, which is archived at merge. Two other +follow-ups from this item were filed as `docs/features/potential/` entries by plan task P8-T9, so +the write set demonstrably permitted that directory; the stated reason for omitting the third +("authoring it was not in this plan's write set", +`evidence/issue-updates/issue-811.2026-09-08T10-44.md` lines 77-78) is contradicted by the plan's own +conduct. A search of `docs/features/potential/` for `ILGlobals`, `SDIL` and `MethodBodyReader` +returns zero matches. + +**Required action.** Author +`docs/features/potential/2026-09-08-ilglobals-loadopcodes-unsynchronised-static-race.md` and promote +it to a GitHub issue through the standard promotion lifecycle. No source change is required on this +branch. + +**Content the entry must carry.** + +- Mechanism: `UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs:123` reassigns + `singleByteOpCodes` to a freshly allocated all-default `OpCode[0x100]` and then fills it in a + reflection loop at line 137. Lines 117-118 declare both tables as plain mutable `public static` + fields. There is no `lock`, no `Lazy`, no `volatile`, and no static constructor. +- Reader: `UtilitiesCS/NewtonsoftHelpers/SDIL Reader/MethodBodyReader.cs:105` and `:110` read the + tables with no synchronisation. +- Participants: `UtilitiesCS.Test/NewtonsoftHelpers/SDILReader/ILGlobals_Tests.cs` calls + `LoadOpCodes()` at lines 15, 26, 37, 47; `MethodBodyReader_Tests.cs` calls it at line 364 inside + the private `CreateReader` helper that every test in that class routes through. Neither class + carries `[DoNotParallelize]`, and the assembly runs at + `[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)]` + (`UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21`). `ClassLevel` scope serialises tests within + a class, so the second participant must be the sibling class. +- Observed signature (AC4 run 7): `GetBodyCode_ReturnsConcatenatedInstructions` failed with + `Expected bodyCode ... to contain "ldstr"`. Only the entry at IL offset 0001 (`ldstr`, opcode + `0x72`) degraded; `nop`, `stloc.0`, `br.s`, `ldloc.0` and `ret` all resolved. The two spaces where + the opcode name belongs are the empty `Name` of `default(OpCode)`, whose `OperandType` is + `InlineBrTarget` (0), which drives the numeric-operand branch and stores a raw metadata token + instead of calling `module.ResolveString(...)`. The printed value 1879067923 lies in the + `0x70……` String-metadata-token range, so the token was read correctly and merely not resolved. + That combination is explained only by a partially filled table read mid-initialisation. +- Frequency: observed once in ten consecutive full-suite runs at 24 class-level workers. The base + rate is not established; two clean baseline runs do not establish absence. +- Candidate fixes, in order of preference: + 1. Publish both tables once and immutably — a static constructor or a `Lazy` pair — and + make `LoadOpCodes()` a no-op or remove it. `LoadOpCodes()` is idempotent and the tables are + conceptually constant, so this removes the mutable window entirely. + 2. Take a `lock` inside `LoadOpCodes()` and around the reads in `MethodBodyReader`. + 3. Interim stopgap only: add `[DoNotParallelize]` to both `ILGlobals_Tests` and + `MethodBodyReader_Tests`. This suppresses rather than eliminates and should not be the final + state, by the same reasoning `spec.md` applies to the console stopgap under AC3. +- Impact statement for the issue: while this is open, the required `mstest-coverage` check can still + fail intermittently on unrelated pull requests, which is the class of harm issue #811 exists to + remove. + +**Acceptance for R-1.** + +- `docs/features/potential/2026-09-08-ilglobals-loadopcodes-unsynchronised-static-race.md` exists on + the branch and carries the mechanism, the two participants with file and line citations, the + observed signature, and the candidate fixes. +- A GitHub issue is created from it and its number is recorded in the entry. +- No source file under `UtilitiesCS/` or `UtilitiesCS.Test/` is modified for this item on this + branch. Fixing the race here would breach the minimal-fix requirement of the CLAUDE.md Bugfix + Workflow and would need its own RED-first plan. + +## Non-blocking Findings — No Remediation Demanded on This Branch + +These are recorded so the orchestrator can route them, not because they gate merge. + +| ID | Source | Summary | Suggested disposition | +|---|---|---|---| +| F-2 | policy-audit | Repository branch coverage 66.3978% is below the 75% floor in `.claude/rules/quality-tiers.md`. Pre-existing (baseline 66.3058%), improved by this change. | Repository-wide programme; do not gate a three-defect determinism bugfix on it. Also note the unreconciled CLAUDE.md 80/no-branch versus rules-file 85/75 conflict. | +| F-3 / C-1 | policy-audit, code-review | `EnumerateTable_WritesFormattedOutputToSuppliedWriterAndMovesToStart` performs two Acts, justified in-code by the 1846-line file's line ceiling. | Follow-up: extract into a small separate test file, which also reduces the over-cap file. | +| F-4 / C-5 | policy-audit, code-review | `DfDeedle.MessageBoxInvoker` remains a mutated process-wide static. Verified to have no concurrent reader today, but the invariant is unenforced and rests on the MSTest phase-ordering assumption `spec.md` Risk 3 flags as unverified. | Add the enforcement caveat to the `DfDeedle_COM_Tests` class header; consider parameterising the seam in separate work. | +| F-5 | policy-audit | `artifacts/csharp/coverage.xml` and the two `artifacts/pr_context.*` files are absent. Coverage substance was verified directly from `coverage/p7-final.cobertura.xml`; the diff was supplied as a pre-generated patch and full diffstat. | Procedural. Regenerate the canonical artifacts in the next cycle if a hook requires them. | +| F-6 | policy-audit, feature-audit | AC4 literally unmet, 9 of 10 runs clean, correctly left unchecked and correctly not re-run to green. | Accept as reported. Re-evaluate AC4 after R-1's race is fixed under its own issue. | +| C-2 | code-review | `Main_RunsSampleScenarioWithoutThrowing` name is stronger than its surviving `NotThrow` assertion. | Optional rename. | +| C-3 | code-review | Two null-writer tests write to the real `Console.Out` under an assembly-wide assumption that nothing captures it. | Add a class-header note recording the dependency. | +| C-6 | code-review | Named and positional tuple access mixed within four lines in `DfDeedle.cs`. | Cosmetic; unify on the named form in a future edit. | + +## Routing + +R-1 is a documentation and promotion action with no source change and no toolchain re-run. It does +not require an atomic plan. It requires one new file under `docs/features/potential/` and one issue +creation, followed by a re-check that the file exists and carries the required content. + +After R-1 is closed, this branch is clear to merge on the evidence recorded in the three audit +artifacts dated 2026-09-08T11-30. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/research/root-cause.2026-09-07T22-10.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/research/root-cause.2026-09-07T22-10.md new file mode 100644 index 000000000..cd9cefed2 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/research/root-cause.2026-09-07T22-10.md @@ -0,0 +1,533 @@ +# Root-cause research: UtilitiesCS.Test nondeterminism (Issue #811; supersedes #780, #803, #594) + +- Timestamp: 2026-09-07T22-10 +- Issue: #811 +- Branch: `bug/utilitiescs-test-determinism-780-803-594-811` (based on `origin/main` at `04a54e681bd21e841e124c016df30672ee701b75`) +- Method: static reading of the worktree with Read/Grep/Glob only. No build, no test run, no git history query was possible in this session (the shell was unavailable). Every line number below is the CURRENT line number in the worktree as of this timestamp. Paths are repository-relative. + +## Headline findings + +1. The #803 maintainer correction is correct on every claim that is verifiable from the tree (claims 1 to 5 and 7 CONFIRMED; claim 6, the millisecond timings, is not verifiable from the tree and is recorded as PARTIALLY CONFIRMED because the code path is consistent with it). +2. The issue Summary's stated mechanism for the `DfDeedle` failure (static-seam pollution by `DfDeedle_COM_Tests`) is REFUTED: the three seams are restored in `finally` blocks, none of them is read on the failing path, and no other test class in the assembly's parallel phase reads them at all. +3. The real mechanism is a 250 ms wall-clock deadline (`250 ms x rowCount`, rowCount = 1 in the test) on two `Task.Run(...).TimeoutAfter(...)` hops inside `OlTableExtensions.EtlByRowAsync`, whose "retry" argument is inert, whose `TimeoutException` is swallowed by `EtlAsync`, and whose null result is then dereferenced at `DfDeedle.cs:186-189`. +4. The `TimeProvider` seam needed for a deterministic fix is ALREADY in place end-to-end: `Microsoft.Bcl.TimeProvider 10.0.11` is referenced by `UtilitiesCS` and `UtilitiesCS.Test`, `Microsoft.Extensions.TimeProvider.Testing 10.9.0` by `UtilitiesCS.Test`, `TimeOutTask.TimeoutAfter` already accepts a `TimeProvider?`, and `DfDeedle.AddQfcColumnsAsync` (issue #798) already threads one. No package change is required. +5. AC1's 500 ms window has no production caller and no legitimate purpose; deletion is the correct fix. +6. AC3's "two `Console.Out` races" is not the count of anything in the current tree: there are FOUR capture-and-assert sites, all four already serialized with `[DoNotParallelize]`. The remaining AC3 work is to replace that serialization stopgap with a `TextWriter` seam. + +--- + +## Section 1 - Verification of the #803 correction + +### Claim 1 - all three static seams are saved and restored in `finally` blocks + +Verdict: CONFIRMED. + +`UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs`: + +- `MessageBoxInvoker`: lines 197-211, 222-236, 261-281, 317-334. Each has the shape `var original = DfDeedle.MessageBoxInvoker; DfDeedle.MessageBoxInvoker = ...; try { ... } finally { DfDeedle.MessageBoxInvoker = original; }`. +- `TableEtlInvoker`: lines 399-414: + ```csharp + var originalEtl = DfDeedle.TableEtlInvoker; + DfDeedle.TableEtlInvoker = _ => (injectedData, injectedColInfo); + try { ... } finally { DfDeedle.TableEtlInvoker = originalEtl; } + ``` +- `StoreTableEtlInvoker`: lines 762-782 and 827-847, same shape. + +Additional finding beyond the correction: the assembly runs at `ExecutionScope.ClassLevel` (`UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21`), so the tests inside `DfDeedle_COM_Tests` execute sequentially on one worker; there is no intra-class race between a writer and a reader of these seams. + +### Claim 2 - none of the seams is reachable from the failing path + +Verdict: CONFIRMED. + +Readers of the three seams in production (`Grep TableEtlInvoker|StoreTableEtlInvoker|MessageBoxInvoker`, `*.cs`, whole repo): + +| Seam | Read site | On the failing path? | +|---|---|---| +| `TableEtlInvoker` | `UtilitiesCS/Extensions/DfDeedle.cs:94` inside the SYNCHRONOUS `GetEmailDataInView(Explorer)` (lines 86-102) | No | +| `StoreTableEtlInvoker` | `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs:143` inside `FromDefaultFolder(Store, ...)` | No | +| `MessageBoxInvoker` | `UtilitiesCS/Extensions/DfDeedle.QfcColumns.cs:27, 170, 194` inside `AddQfcColumns` / `EnsureTriageColumnExists` | Reachable in principle from `AddQfcColumnsAsync`, but only when the folder lacks the `Triage` user-defined property. The failing test builds the folder with `BuildFolderWithUdp("Triage")` (test line 434), so `HasUserDefinedProperty` returns `true` at `DfDeedle.QfcColumns.cs:165-168` and no dialog seam is invoked. | + +The failing path `GetEmailDataInViewAsync` (`DfDeedle.cs:139-219`) calls `table.EtlAsync(...)` directly at line 180: + +```csharp +var tableSnapshot = await table.EtlAsync( + token, + tokenSource, + 0, + progress.Increment(2).SpawnChild(96) +); +``` + +Cross-class observability check (`Grep DfDeedle\.(FromDefaultFolder|GetEmailDataInView|AddQfcColumnsAsync)\(`): the only test callers are `DfDeedle_COM_Tests.cs` (lines 405, 507, 525, 547, 573, 602, 768, 833) and `DfDeedleQfcColumnTimeoutTests.cs:121`. The latter class is `[DoNotParallelize]` (its line 22). No other test class in the parallel phase can observe a swapped seam. The Summary's "static seam mutation ... races other classes" therefore describes a race that has no second participant. + +### Claim 3 - `EtlAsync` sets `milliseconds = 250 * rowCount` and `EtlByRowAsync` awaits two `Task.Run(...).TimeoutAfter(250, 3)` + +Verdict: CONFIRMED. + +`UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs:80-82`: + +```csharp +var rowCount = table.GetRowCount(); +int milliseconds = 250 * rowCount; +var attempts = 3; +``` + +The test's strict mock returns `GetRowCount() == 1` (`DfDeedle_COM_Tests.cs:442`), so `milliseconds == 250`. + +Branch selection at lines 90-107: `MAPIFields.BinaryToStringFields` (`UtilitiesCS/OutlookObjects/Fields/MAPIFields.cs:121-127`) contains `"ConversationId"`, and the test's column dictionary contains `ConversationId` (test lines 453-460), so the `EtlByRowAsync` branch is taken (line 98), not the `GetArray` branch. + +`EtlByRowAsync` (private, lines 229-263) awaits two timed hops: + +```csharp +var rows = await Task.Run(() => table.CastToRowArray(progress?.SpawnChild(65)), token) + .TimeoutAfter(timeout, attempts); // lines 244-245 +... +var jagged = await Task.Run( + () => rows.EtlByRow(objectConverters, binIndices, objFields, objIndices, progress?.SpawnChild()), + token + ) + .TimeoutAfter(timeout, attempts); // lines 248-259 +``` + +Each hop is a separate 250 ms wall-clock deadline that starts when `TimeoutAfter` is called, i.e. before the thread pool has scheduled the `Task.Run` body. + +### Claim 4 - the retry count is inert because `TimeoutAfter(int)` returns a faulted proxy rather than throwing + +Verdict: CONFIRMED. + +`UtilitiesCS/Threading/TimeOutTask.cs:824-849` (generic) and `924-940` (non-generic): + +```csharp +public static Task TimeoutAfter(this Task task, int millisecondsTimeout, int repeatAttempts) +{ + Task result = null!; + try + { + result = task.TimeoutAfter(millisecondsTimeout); + } + catch (TimeoutException) + { + logger.Warn($"Task timed out. {repeatAttempts} attempts remaining."); + if (repeatAttempts > 0) + { + result = task.TimeoutAfter(millisecondsTimeout, repeatAttempts - 1); + } + ... + } + return result!; +} +``` + +`task.TimeoutAfter(millisecondsTimeout)` binds to the overload at lines 862-922 (`TimeoutAfter(this Task, int, TimeProvider? = null)`). That overload never throws: it returns `task` itself (line 873), or a proxy `tcs.Task` (line 921) that a `TimeProvider` timer later faults via `myTcs.TrySetException(new TimeoutException())` (line 895). Even the zero-timeout short-circuit (lines 880-885) faults the proxy with `tcs.SetException` rather than throwing. The `catch (TimeoutException)` at line 836 therefore cannot execute, `repeatAttempts` is never consulted, and the log line "attempts remaining" is unreachable. + +The only tests of this overload (`UtilitiesCS.Test/Threading/TimeOutTask_Tests.cs:191-215`) pass already-completed tasks, which hit the `task.IsCompleted` short-circuit, so the inert branch is also untested. + +### Claim 5 - on expiry `EtlAsync` swallows the exception and returns `(data!, columnDictionary)` with `data` null; `DfDeedle.cs:186` dereferences it + +Verdict: CONFIRMED. + +`OlTableExtensions.Etl.cs:83` initialises `object[,]? data = null;`. Lines 117-123 and 129: + +```csharp +catch (TimeoutException) +{ + logger.Error( + $"{DateTime.Now.ToString("mm:ss.fff")} {nameof(ETL)} timed out {attempts} times with a timeout of {milliseconds} milliseconds. Canceling" + ); + tokenSource.Cancel(); +} +... +return (data!, columnDictionary); +``` + +When the proxy from either hop faults, the `await` inside `EtlByRowAsync` rethrows `TimeoutException`, it propagates out of `EtlByRowAsync` into the `catch` above, `data` remains `null`, and the null-forgiving `data!` places a null into the non-nullable tuple element `object[,] data`. `tokenSource.Cancel()` is called, but in the test the `tokenSource` is a fresh `new CancellationTokenSource()` (test line 480) unrelated to the `CancellationToken.None` passed as `token`, so nothing downstream observes the cancellation. + +`UtilitiesCS/Extensions/DfDeedle.cs:186-189`: + +```csharp +LogDfTiming( + "GetEmailDataInViewAsync table snapshot ready | table snapshot", + $"rowCount={tableSnapshot.Item1.GetLength(0)}; columnCount={tableSnapshot.Item1.GetLength(1)}; etlElapsedMs={etlStopwatch.ElapsedMilliseconds}" +); +``` + +The statement begins at line 186; the dereference `tableSnapshot.Item1.GetLength(0)` is on line 188. The CI stack trace's "line 186" is the sequence point of the multi-line invocation statement, consistent with this site. + +Additional finding: in production, `QuickFiler/Controllers/QfcDatamodel.FrameBuilding.cs:82-109` wraps the call in `catch (TaskCanceledException)` (returns null quietly) and `catch (System.Exception)` (logs and rethrows). Because the `NullReferenceException` at line 188 fires before the cancelled token is next consulted (line 204 `Task.Run(..., token)`), a real Outlook ETL timeout surfaces as an unattributed NRE rather than the intended cancellation. The defect is a production defect, not only a test defect. + +### Claim 6 - timings of 99 ms and 96 ms on green runs versus 389 ms on the failing run against a 250 ms deadline + +Verdict: PARTIALLY CONFIRMED (mechanism consistent; the numbers are not verifiable from this tree). + +No TRX from the PR #802 run exists in the worktree. The only committed TRX records of this test failing are in `docs/features/archive/2026-06-08-ci-flaky-test-isolation-176/evidence/` (`baseline-full.trx:1143`, `full2.trx:4241`, `run1.trx:8751`); those record durations of 9.8 ms to 11.7 ms and a DIFFERENT failure, `System.TypeInitializationException: The type initializer for 'Moq.Async.AwaitableFactory' threw an exception ... Could not load file or assembly 'System.Threading.Tasks.Extensions, Version=4.2.0.1'` (a June 2026 assembly-binding fault, since resolved). They do not corroborate or contradict the 389 ms figure. + +What the code does establish: the test path performs at least four thread-pool scheduling hops (`AddQfcColumnsAsync` `Task.Run` at `DfDeedle.QfcColumns.cs:119`, two `Task.Run` in `EtlByRowAsync`, and `Task.Run` at `DfDeedle.cs:204`) plus a progress `Timer` (`Etl.cs:364-373`), under 24 class-level workers and coverage instrumentation. A 250 ms budget for a hop that includes pool scheduling latency is a plausible thing to exceed under that load, and nothing else in the path has a deadline that small (next smallest is 1000 ms at `DfDeedle.cs:208`). + +### Claim 7 - the #798 guard at `DfDeedle.cs:195` runs after the line-186 dereference + +Verdict: CONFIRMED. + +`DfDeedle.cs:191-195`: + +```csharp +// Guards the row builder's unchecked column indexing. ... +ValidateRequiredEmailColumns(tableSnapshot.Item2, folderName); +``` + +It executes after lines 186-189, and it inspects `Item2` (the column map), never `Item1` (the data array). Even if it were hoisted above line 186 it would not guard this path. + +--- + +## Section 2 - Root cause per acceptance criterion + +### AC1 / issue #780 - `DictionaryExtensions.TryAddValuesAsync` + +Current signature and full body, `UtilitiesCS/Extensions/DictionaryExtensions.cs:169-180` (file carries `#nullable enable` at line 15): + +```csharp +public static async Task TryAddValuesAsync( + this ConcurrentDictionary dictionary, + TKey key, + TValue value, + CancellationToken token +) +{ + var linkedTS = CancellationTokenSource.CreateLinkedTokenSource(token); + linkedTS.CancelAfter(500); + + return await Task.Run(() => dictionary.TryAddValues(key, value), linkedTS.Token); +} +``` + +The hard-coded window is `linkedTS.CancelAfter(500);` at line 177. Note also that `linkedTS` is never disposed, so every call leaks a timer until it fires. + +Call sites (search: `TryAddValuesAsync` over `*.{cs,vb,xaml,ps1}`, whole repository, 3 hits): + +| Hit | Kind | +|---|---| +| `UtilitiesCS/Extensions/DictionaryExtensions.cs:169` | definition | +| `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs:237` | test method name | +| `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs:244` | the only invocation: `await dictionary.TryAddValuesAsync("value", 2, CancellationToken.None)` | + +There is NO production caller. + +What the window actually does: the linked token is passed only to `Task.Run`, so it can cancel the work only BEFORE the pool starts it (a `Task.Run` body cannot be interrupted by its token once running). The body `TryAddValues` (lines 123-136) is a compare-and-swap loop over `ConcurrentDictionary.TryUpdate` that completes in one iteration absent contention and is bounded in practice. The window therefore measures thread-pool scheduling latency, not the operation, and converts latency above 500 ms into `TaskCanceledException`. That is exactly the failure recorded (`d__10\`2.MoveNext() ... line 179`, the `await` line). + +Observations per caller if the window is removed versus made `TimeProvider`-driven: + +- Removed: the only caller (the test) observes `true` and the updated value regardless of scheduling latency; a pre-cancelled `token` still yields `TaskCanceledException` from `Task.Run`, so caller-driven cancellation semantics are unchanged. +- `TimeProvider`-driven: identical for the test if it passes an un-advanced `FakeTimeProvider`; adds a parameter with no production consumer and preserves a deadline that has no semantic on a CAS loop. + +Does the 500 ms window have a legitimate production purpose? No. There is no production consumer, the body cannot hang on I/O, and the token cannot interrupt the body anyway. It is purely defensive and is itself the defect. + +Recommendation: DELETE the linked token source and `CancelAfter`; pass the caller's `token` straight to `Task.Run`: + +```csharp +return await Task.Run(() => dictionary.TryAddValues(key, value), token); +``` + +This satisfies AC1's first disjunct ("no longer cancels on a fixed wall-clock window"). Tests: keep `TryAddValuesAsync_UpdatesExistingValue` (it becomes deterministic with no change); add one test that a pre-cancelled token yields `TaskCanceledException` (locks the surviving contract) and, if the planner wants a RED-first artifact for AC1, note that the existing test cannot be made to fail deterministically before the fix without a sleep (the failure is load-dependent), so the RED test for AC1 should instead assert the contract shape (for example, via a `CancellationToken` that is cancelled after the work has been observed to start, proving the outer token and not an internal timer governs cancellation). + +### AC2 / issues #803, #594 item 1 - `DfDeedle.GetEmailDataInViewAsync` and `DfDeedle_COM_Tests` + +Current body of `GetEmailDataInViewAsync`, `UtilitiesCS/Extensions/DfDeedle.cs:139-219` (file has `#nullable enable` at line 23), abridged to the load-bearing lines: + +```csharp +139 public static async Task> GetEmailDataInViewAsync( +140 Explorer activeExplorer, CancellationToken token, CancellationTokenSource tokenSource, ProgressTracker progress) +145 { +146 token.ThrowIfCancellationRequested(); +156 Outlook.Table table = await activeExplorer.GetTableInViewAsync(token, 0); +158 var currentFolder = activeExplorer.CurrentFolder; +159 var storeID = activeExplorer.CurrentFolder.StoreID; +166 var folderName = currentFolder?.Name ?? "(unknown folder)"; +176 await AddQfcColumnsAsync(table, currentFolder!, token, 0); +179 var etlStopwatch = Stopwatch.StartNew(); +180 var tableSnapshot = await table.EtlAsync(token, tokenSource, 0, progress.Increment(2).SpawnChild(96)); +186 LogDfTiming("GetEmailDataInViewAsync table snapshot ready | table snapshot", +188 $"rowCount={tableSnapshot.Item1.GetLength(0)}; columnCount={tableSnapshot.Item1.GetLength(1)}; ..."); // <-- dereference +195 ValidateRequiredEmailColumns(tableSnapshot.Item2, folderName); // <-- #798 guard +204 Frame df = await Task.Run(() => Email2dArrayToDf(storeID, tableSnapshot.Item1, tableSnapshot.Item2), token) +208 .TimeoutAfter(1000, 2); // <-- second inert-retry deadline +217 progress.Report(100); +218 return df; +219 } +``` + +`EtlAsync` / `EtlByRowAsync` bodies: quoted in Section 1, claims 3 and 5 (`OlTableExtensions.Etl.cs:66-130` and `229-263`). `TimeoutAfter` overloads: `TimeOutTask.cs:824-849` (`(Task, int, int)`), `862-922` (`(Task, int, TimeProvider?)`), `924-940` (`(Task, int, int)`), `949-1009` (`(Task, int, TimeProvider?)`). + +`DfDeedle_COM_Tests` static-seam usage: Section 1, claim 1. The failing test itself (lines 422-489) touches no static seam. + +Other wall-clock deadlines on the same test path, in execution order (all currently system-clock driven and all untouched by the test): + +| Site | Deadline | Seam available today | +|---|---|---| +| `DfDeedle.cs:156` -> `OlTableExtensions.TableAccess.cs:31-118` `GetTableInViewAsync` | 2000 ms via `TimeOutTask.RunWithTimeout(view.GetTable, token, 2000, 1, false, timeoutSourceFactory)` (line 56-63), plus up to two outer retries (`counter < 2`, lines 79, 97) | `Func? timeoutSourceFactory` (line 36); NOT passed by `DfDeedle.cs:156` | +| `DfDeedle.cs:176` -> `DfDeedle.QfcColumns.cs:102-156` `AddQfcColumnsAsync` | 3 x 3000 ms | `TimeProvider? timeProvider` (line 108); NOT passed by `DfDeedle.cs:176` | +| `DfDeedle.cs:180` -> `Etl.cs:244-245, 248-259` | 2 x 250 ms | none (int retry count only) | +| `DfDeedle.cs:204-208` | 1000 ms | none (int retry count only) | + +The 250 ms pair is the one that tripped; the 1000 ms one is the same mechanism with four times the margin and would produce the same NRE-free but still nondeterministic `TimeoutException` if it ever expired (that proxy is awaited directly, so it throws rather than returning null). + +MINIMAL production change set that makes the failing path deterministic with no timing tolerance: + +1. `OlTableExtensions.Etl.cs` - `EtlAsync`: add a trailing optional parameter `TimeProvider? timeProvider = null`; replace `.TimeoutAfter(milliseconds, attempts)` at line 114 with `.TimeoutAfter(milliseconds, timeProvider)`; pass `timeProvider` into `EtlByRowAsync`. `EtlByRowAsync` (line 229): replace `int attempts` with `TimeProvider? timeProvider`; lines 245 and 259 become `.TimeoutAfter(timeout, timeProvider)`. Drop the now-unused `attempts` local (line 82) and correct the log text at line 120, which currently claims "timed out {attempts} times" for a retry that never happened (also an opportunity to remove the banned `DateTime.Now` on that line, which is at RS0030 suggestion severity). Behaviour with `timeProvider == null` is byte-for-byte the current behaviour, because the `(int, int)` overload already delegated to the `(int, TimeProvider? = null)` overload and its retry was inert. +2. `DfDeedle.cs` - `GetEmailDataInViewAsync`: add a trailing optional parameter `TimeProvider? timeProvider = null`; pass it to `AddQfcColumnsAsync(..., timeProvider: timeProvider)` at line 176, to `EtlAsync(..., timeProvider: timeProvider)` at line 180, and replace `.TimeoutAfter(1000, 2)` at line 208 with `.TimeoutAfter(1000, timeProvider)`. +3. `DfDeedle.cs` - insert, between line 185 and line 186, the AC2 guard: + ```csharp + if (tableSnapshot.data is null) + { + throw new InvalidOperationException( + $"The table snapshot for folder '{folderName}' was not produced: the table ETL " + + "timed out or was cancelled before returning any rows, so the email data " + + "frame cannot be built for this folder."); + } + ``` + The file is under `#nullable enable`; a null test on a non-nullable tuple element compiles without a nullable diagnostic. `folderName` is already in scope (line 166). This mirrors the wording style of `ValidateRequiredEmailColumns` (`DfDeedle.QfcColumns.cs:289-294`). + +Test-side change for the currently failing test (`DfDeedle_COM_Tests.cs:477-482`): pass `timeProvider: new FakeTimeProvider()` and never advance it. Every deadline on the path is then armed on a clock that does not move, so no deadline can fire regardless of host load, and the test asserts exactly what it asserted before. This is not a timing tolerance: the deadline is not widened, it is placed under the test's control. + +Widening the 250 ms deadline (or raising `GetRowCount()` in the mock, as `OlTableExtensions_Tests.cs:960-963` already does with `Returns(120)` "so the timeout cannot fire under test-host contention") is an AC5 violation and is rejected. The existing `Returns(120)` workaround is itself a timing tolerance in the tree; the plan should convert that test to the fake clock in the same change since `EtlAsync` gains the parameter anyway. + +AC2's seam clause ("no longer mutates process-wide static seams in a way another class can observe"): as established in Section 1, no other class can observe them today, so the clause is already true by absence of a reader. To make it true by construction, the recommended disposition is to convert the two ETL delegate statics into optional parameters and delete the statics: + +- `DfDeedle.cs:69-72` `TableEtlInvoker` -> parameter `Func columnInfo)>? etl = null` on `GetEmailDataInView(Explorer)` (production caller: `QfcDatamodel.FrameBuilding.cs:15`, source-compatible). +- `DfDeedle.cs:81-84` `StoreTableEtlInvoker` -> same optional parameter on `FromDefaultFolder(Store, ...)` (`DfDeedle.FrameUtilities.cs:125`, read at line 143; production callers `ToDoModel/Data Model/ID/IDList.cs:130, 226` and `DfDeedle.FrameUtilities.cs:160`, all source-compatible). +- `MessageBoxInvoker` (`DfDeedle.cs:54-60`) stays static: it is a modal-dialog stub reached through private methods that tests invoke by reflection with fixed argument arrays; parameterising it would rewrite five tests for no determinism gain. Document it in the test-class header as restored-in-`finally` and reader-free. + +This disposition is severable. If the planner prefers the smallest diff, the fallback the issue text itself sanctions is `[DoNotParallelize]` on `DfDeedle_COM_Tests` with a comment stating the three seams are process-global, that this class is their only writer, and that the attribute exists to keep that true if a reader is ever added. That fallback masks nothing today because nothing races. + +### AC3 / issue #594 items 2 and 3 - `Console.Out` races + +Search performed over every `*.Test` project (`Console\.SetOut|Console\.SetError|Console\.Out\b|Console\.Error\b|StringWriter`, glob `*.Test/**/*.cs`), then restricted to `UtilitiesCS.Test` for counts. No `Console.SetError` or `Console.Error` use exists in any test project. Findings for `UtilitiesCS.Test`: + +Capture-and-assert sites (a `StringWriter` installed with `Console.SetOut`, restored in `finally`, then asserted on). These are the VICTIM side of a race: + +| # | File | Class | Test method | Lines | Production member exercised | +|---|---|---|---|---|---| +| 1 | `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs` | `StackGeek_Tests` | `Main_RunsSampleScenarioWithoutThrowing` | 151-167 | `GFG.Main` writes via `Console.WriteLine` (`UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs:187-191`) | +| 2 | `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs` | `PrettyPrint_Tests` | `DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput` | 194-219 | `PrettyPrinters.PrettyPrint(DataFrame)` / `PrettyPrint(DataFrameRow)` (`UtilitiesCS/HelperClasses/PrettyPrint.cs:25, 27`) | +| 3 | `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs` | `DASLFilterParserTests` | `PrintTree_WritesIndentedTreeToConsole` | 107-122 | `DASLFilterParser.PrintTree` (`UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs:97-104`) | +| 4 | `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` | `OlTableExtensions_Tests` | `EnumerateTable_WritesFormattedOutputAndMovesToStart` | 1635-1651 | `OlTableExtensions.EnumerateTable` (`UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs:423-425`) | + +All four classes ALREADY carry `[DoNotParallelize]` (lines 15, 19, 14 and 20 respectively), each with a comment naming this exact race as the reason. The duplicate `UtilitiesCS.Test/OutlookObjects/DASLFilterParser_Tests.cs` that issue #520 identified as the colliding twin no longer exists (Glob `**/DASLFilterParser*Tests*.cs` returns only the `Filter DASL` file). + +Aggressor sites (replace `Console.Out` and either never restore it or restore it at an arbitrary time). None of these asserts on console content, so none can be a victim: + +- 24 files call `Console.SetOut(new DebugTextWriter())` from a `[ClassInitialize]`/`[TestInitialize]` method with no restore (for example `Frexp_Test.cs:16`, `BayesianClassifierTests.cs:22`, `ScoDictionaryConverterTests.cs:25`, `Triage_OlLogicTests.cs:27`; `ObsoleteBayesianClassifier_Tests.cs` does it twice at 61 and 476). `DebugTextWriter` is `UtilitiesCS/HelperClasses/Logging/DebugTextWriter.cs:12` (a `StreamWriter` over `Debug`); two nested copies also exist at `UtilitiesCS.Test/DeedleTests.cs:28` and `UtilitiesCS.Test/Extensions/DeedleTests.cs:27`. +- `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs:22-23, 56` sets a `DebugTextWriter` in `[TestInitialize]` and restores in `[TestCleanup]`; not `[DoNotParallelize]`; its tests (lines 59-117) verify a mock logger, not console text. + +Mechanism of interference (as described in issue #520 and consistent with the code): class A saves `originalOut` and installs `writer_A`; class B on another worker saves `originalOut` (now `writer_A`) and installs `writer_B`; A's `finally` restores the real console, detaching `writer_B`; B's production call writes to the console and `writer_B.ToString()` is empty, so B's `Contain(...)` assertion fails. With four victims and roughly 25 aggressors, any victim could lose. + +Is the count two? No. The count of capture-and-assert sites in the current tree is FOUR (derivation in the Numeric Derivation Evidence section below). "Two" in #594 is most plausibly the number of such failures observed in one run, which the tree cannot verify. Under MSTest's documented execution model, tests marked `[DoNotParallelize]` run sequentially after the parallel set completes, so as of the current tree none of the four victims can overlap an aggressor; the race is currently suppressed by the stopgap, not eliminated. (This ordering claim is from the MSTest adapter's documented behaviour; it was not confirmed by a run in this session.) + +Recommended fix (eliminate the shared-console dependency; `.claude/rules/csharp.md` "DI Seams" preference 2, injectable delegate/argument seam): + +| Production member | Change | Non-test callers that must remain source-compatible | +|---|---|---| +| `DASLFilterParser.PrintTree(TreeNode node, int level)` (`DASLFilterParser.cs:97`, file has `#nullable enable`) | Add `TextWriter? writer = null`; body writes to `(writer ?? Console.Out)` and passes `writer` on recursion | `UtilitiesCS/EmailIntelligence/ClassifierGroups/Triage/Triage_OlLogic.cs:74`; test `Triage_OlLogicTests.cs:142` | +| `PrettyPrinters.PrettyPrint(this DataFrame)` and `PrettyPrint(this DataFrameRow)` (`PrettyPrint.cs:25, 27`, `#nullable enable`) | Add `TextWriter? writer = null`; `(writer ?? Console.Out).WriteLine(...)`. Zero line growth (the file is already 680 lines, over the 500-line cap; do not grow it) | `TaskMaster/Ribbon/TryFunctionalityInConstruction.cs:66` | +| `OlTableExtensions.EnumerateTable(this Outlook.Table)` (`TableAccess.cs:381`, `#nullable enable`) | Add `TextWriter? writer = null`; replace the three `Console.WriteLine` at 423-425 | `TaskMaster/Ribbon/TryFunctionalityInConstruction.cs:51` | +| `GFG.Main(String[] args)` (`StackGeek.cs:173`, `#nullable enable`) | Extract the body into `public static void Run(TextWriter writer)` and have `Main` call `Run(Console.Out)` | none (sample code; no callers outside the test) | + +Test-side: the four tests pass their own `StringWriter` and stop touching `Console.Out`. Then remove `[DoNotParallelize]` and its comment from `StackGeek_Tests`, `DASLFilterParserTests`, `PrettyPrint_Tests` and `OlTableExtensions_Tests`, whose comments cite the console redirect as the sole reason. Removing the attribute is what makes the AC4 ten-run gate exercise the seam rather than the stopgap. If the planner judges `OlTableExtensions_Tests` (1855 lines, many COM-mock tests) too large to re-parallelize in a bugfix, keep its attribute but rewrite the comment so it no longer claims the console as the reason. + +Out of scope but recorded: the ~25 `DebugTextWriter` aggressors remain process-global mutations with no restore; once no test captures `Console.Out` they harm nothing, but they are the reason `Console.Out` is an arbitrary writer for the rest of the run. `OlTableExtensions.TableAccess.cs:78, 96` also write diagnostics with `Console.WriteLine` in production instead of the logger. + +### AC4 - the determinism gate + +Workflow: `.github/workflows/_mstest-coverage.yml`, called from `.github/workflows/ci.yml:30-32` as job `mstest-coverage`. Discovery (lines 86-92) takes every `*.Test.dll` under `\bin\Debug\` excluding `\obj\` and `\ref\`. The nine assemblies (each `.csproj` sets `bin\Debug\` for the Debug|AnyCPU configuration): + +``` +QuickFiler.Test\bin\Debug\QuickFiler.Test.dll +SVGControl.Test\bin\Debug\SVGControl.Test.dll +Tags.Test\bin\Debug\Tags.Test.dll +TaskMaster.Test\bin\Debug\TaskMaster.Test.dll +TaskTree.Test\bin\Debug\TaskTree.Test.dll +TaskVisualization.Test\bin\Debug\TaskVisualization.Test.dll +ToDoModel.Test\bin\Debug\ToDoModel.Test.dll +UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll +VBFunctions.Test\bin\Debug\VBFunctions.Test.dll +``` + +CI invocation, verbatim from `_mstest-coverage.yml:99`: + +``` +& $vstestPath $testAssemblies /EnableCodeCoverage /InIsolation /Logger:trx /TestCaseFilter:"TestCategory!=LiveOutlook" +``` + +Two properties of that invocation matter for fidelity: + +- CI passes NO `/Settings:`. Parallelism therefore comes only from `[assembly: Parallelize(Workers = 0, Scope = ClassLevel)]` in `UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21`; no other test project declares the attribute (Grep `Parallelize` over `*.cs`), so in CI only `UtilitiesCS.Test` runs in parallel. `Workers = 0` means the processor count of the host: 24 on the local workstation, and whatever the GitHub-hosted `windows-latest` runner provides (GitHub's published specification is 4 cores for standard runners; not verifiable from the tree). The failures reproduced in both environments. +- The local scripts differ: `scripts/vscode/Invoke-MSTest.ps1:54` and `Invoke-MSTestWithCoverage.ps1:76` add `/Settings:scripts\vscode\TaskMaster.cli.runsettings`, which applies `Workers 0 / ClassLevel` to ALL nine assemblies. A local run is therefore a heavier load than CI for the other eight assemblies. + +Known local-only obstacles (from `docs/features/active/2026-09-06-quickfiler-crash-column-add-timeout-swallowed-keynotfound-798/evidence/baseline/`): + +- `shell-icon-stall-probe.md` (2026-09-07T00-55): the four shell-icon classes (`ShellUtilities_Tests`, `ShellUtilitiesStatic_Tests`, `SysImageListHelper*`, `OSBrowser*`) no longer stall the testhost through `SHGetFileInfo` on this workstation, but one of the 23 tests fails per run with `Win32 handle that was passed to Icon is not valid`, the failing test moving between sibling classes. Verdict recorded: `SHELL_ICON_EXCLUSION: REQUIRED`, filter extension `&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser`. CI runs those classes unfiltered. +- `vstest-coverage-baseline.md` (2026-09-07T01-00): `/InIsolation` is required; assembly discovery from a worktree under `.claude/worktrees/` must apply the dot-claude exclusion to the path RELATIVE to the worktree root, or the discovery returns zero assemblies and the gate is vacuous; `/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None` was used as a hang guard. That baseline (9 assemblies, no in-proc coverage, dotnet-coverage collect wrapper) executed 7023 tests in 54.5 s. + +Proposed concrete ten-run gate (PowerShell, run from the worktree root; `` is this feature folder): + +```powershell +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +$vstest = & $vswhere -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +$assemblies = @( + 'QuickFiler.Test\bin\Debug\QuickFiler.Test.dll','SVGControl.Test\bin\Debug\SVGControl.Test.dll', + 'Tags.Test\bin\Debug\Tags.Test.dll','TaskMaster.Test\bin\Debug\TaskMaster.Test.dll', + 'TaskTree.Test\bin\Debug\TaskTree.Test.dll','TaskVisualization.Test\bin\Debug\TaskVisualization.Test.dll', + 'ToDoModel.Test\bin\Debug\ToDoModel.Test.dll','UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll', + 'VBFunctions.Test\bin\Debug\VBFunctions.Test.dll') +$filter = 'TestCategory!=LiveOutlook&FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser' +foreach ($run in 1..10) { + & $vstest $assemblies /EnableCodeCoverage /InIsolation ` + "/Logger:trx;LogFileName=ac4-run-$run.trx" "/ResultsDirectory:coverage\trx\ac4" ` + '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' "/TestCaseFilter:$filter" + if ($LASTEXITCODE -ne 0) { throw "AC4 run $run failed with exit code $LASTEXITCODE" } +} +``` + +Then read `` from each of the ten TRX files and record a ten-row table (run, total, passed, failed, wall-clock) in `docs/features/active//evidence/regression-testing/ac4-ten-run..md`. Do not commit the raw TRX or `.coverage` files: the TRX embeds `computerName` and absolute paths, and `/EnableCodeCoverage` writes large binary `.coverage` files; `coverage\` is gitignored. `LogFileName=` is set so the default `__.trx` naming is not produced. + +Honest duration estimate: the #798 baseline measured 54.5 s per nine-assembly run without in-proc coverage. `/EnableCodeCoverage` adds instrumentation overhead that has not been measured for the full nine-assembly set in any committed evidence (#780 reports 35 s for two assemblies with coverage). Expect roughly 1 to 2 minutes per run, i.e. 10 to 20 minutes for the gate, plus one build. The filter deviation (shell-icon exclusion) must be recorded alongside the result, and the CI run on the PR provides the unfiltered form. + +Note that the local gate, because of the runsettings difference above, should NOT pass `/Settings:` if CI parity is the goal; the command above omits it deliberately so that parallelism comes from the assembly attribute exactly as in CI. + +### AC5 - the no-timing-hack constraint + +Every timing construct in the files the fix will touch, with disposition: + +| File | Line(s) | Construct | Disposition | +|---|---|---|---| +| `UtilitiesCS/Extensions/DictionaryExtensions.cs` | 176-177 | `CreateLinkedTokenSource(token)` + `CancelAfter(500)` | REMOVE (AC1) | +| `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` | 81 | `int milliseconds = 250 * rowCount` | keep value; deadline becomes clock-driven | +| same | 82 | `var attempts = 3` (inert) | remove | +| same | 114 | `.TimeoutAfter(milliseconds, attempts)` | -> `.TimeoutAfter(milliseconds, timeProvider)` | +| same | 120 | `DateTime.Now` (banned symbol, suggestion severity) in the timeout log | remove while editing the line | +| same | 147-148, 158 | `EtlAsyncOld`: same 250 ms x rows deadline and inert retry | dead in production (only caller `OlTableExtensions_Tests.cs:1005`); leave or delete as the planner decides; deleting frees 38 lines in a 475-line file | +| same | 245, 259 | `.TimeoutAfter(timeout, attempts)` | -> `.TimeoutAfter(timeout, timeProvider)` | +| same | 316-325, 364-373 | `new Timer(..., 0, 500)` progress-report timers | unchanged; they only call `progress.Report`, which the test's `SilentProgressTracker` no-ops | +| `UtilitiesCS/Extensions/DfDeedle.cs` | 156 | `GetTableInViewAsync(token, 0)` (2000 ms window inside, up to 3 attempts) | unchanged; see Section 5 | +| same | 176 | `AddQfcColumnsAsync(..., 0)` (3 x 3000 ms) | pass `timeProvider` | +| same | 180 | `EtlAsync(...)` | pass `timeProvider` | +| same | 208 | `.TimeoutAfter(1000, 2)` (inert retry) | -> `.TimeoutAfter(1000, timeProvider)` | +| `UtilitiesCS/Extensions/DfDeedle.QfcColumns.cs` | 21, 127 | `AttemptLimit = 3`, `TimeoutAfter(3000, timeProvider)` | unchanged (already seamed by #798) | +| `UtilitiesCS/Threading/TimeOutTask.cs` | 824-849, 924-940 | inert `(int, int)` overloads | unchanged; file is 1012 lines (over cap) and the overloads keep two test callers (`TimeOutTask_Tests.cs:197, 210`) and `EtlAsyncOld`; recommend a follow-up to delete them | +| `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs` | 35, 56-63, 79, 97 | `timeoutMs = 2000`, `RunWithTimeout`, `counter < 2` retries | unchanged (only `EnumerateTable` is touched for AC3) | +| `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` | - | no `Thread.Sleep`, `Task.Delay`, `CancelAfter`, `TimeoutAfter`, `WaitOne`, retry or stopwatch (Grep over the file returned nothing) | - | +| `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs` | - | none | - | +| `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` | 960-963 | `mockTable.Setup(t => t.GetRowCount()).Returns(120)` with the comment "so the timeout cannot fire under test-host contention" | an existing timing tolerance on the very method under repair; convert to the fake clock | +| same | 72-110, 802-825 | `RunTableRetry` tests | logic tests of a retry helper, not timing; unchanged | +| `UtilitiesCS.Test/Threading/TimeOutTask_Tests.cs` | 197, 210 | `TimeoutAfter(100, 3)` on already-completed tasks | short-circuit path; not timing-dependent; unchanged | +| `StackGeek_Tests.cs`, `PrettyPrint_Tests.cs`, `DASLFilterParserTests.cs`, `NLogTraceWriter_Test.cs` | - | none | - | + +`BannedSymbols.txt` (repository root, 7 entries, verbatim): + +``` +P:System.DateTime.Now;Do not use DateTime.Now. Inject System.TimeProvider and call GetLocalNow() for testable, deterministic time. +P:System.DateTime.UtcNow;Do not use DateTime.UtcNow. Inject System.TimeProvider and call GetUtcNow() for testable, deterministic time. +P:System.Random.Shared;Do not use Random.Shared. Inject a seeded or deterministic random source for testability. +M:System.Threading.Thread.Sleep(System.Int32);Do not call Thread.Sleep. Use async/await with an injected time abstraction or a cancellation token. +M:System.Threading.Thread.Sleep(System.TimeSpan);Do not call Thread.Sleep. Use async/await with an injected time abstraction or a cancellation token. +M:System.Threading.Tasks.Task.Delay(System.Int32);Do not call Task.Delay directly in production code. Inject a time abstraction (System.TimeProvider). +M:System.Threading.Tasks.Task.Delay(System.TimeSpan);Do not call Task.Delay directly in production code. Inject a time abstraction (System.TimeProvider). +``` + +RS0030 severity: `suggestion` (`.editorconfig:545-548`: "RS0030 held at suggestion for initial rollout. Promotion to warning is a post-cleanup follow-up (143 existing banned-symbol usages, see issue #181 evidence)"). Consequence for the plan: the analyzer will NOT fail the build on a new `Thread.Sleep`/`Task.Delay`; AC5 must be enforced by a grep gate over the diff, not by the toolchain. `CancelAfter`, `TimeoutAfter`, `WaitOne` and `new CancellationTokenSource(int)` are not in `BannedSymbols.txt` at all. The analyzer is wired into `UtilitiesCS.csproj:1312-1314` and `UtilitiesCS.Test.csproj:973-975` via `Microsoft.CodeAnalysis.BannedApiAnalyzers 5.6.0` with ``. + +--- + +## Numeric Derivation Evidence + +AC3 states a number ("the two `Console.Out` races"). The derivation below is for the population the fix must address: test methods in `UtilitiesCS.Test` that capture `Console.Out` into a writer and assert on its content. + +- Complete Family: test methods in `UtilitiesCS.Test` that (a) call `Console.SetOut` with a writer they own, and (b) assert on that writer's captured text. +- Exhaustive Search Scope: every `*.cs` file under `UtilitiesCS.Test/` (the assembly that carries the class-level `Parallelize` attribute). Other test assemblies were searched with the same primary pattern for completeness and hold no capture-and-assert sites (`VBFunctions.Test/ComputerInfo_Test.cs:15`, six `QuickFiler.Test` files, `TaskMaster.Test/AppGlobals/AppToDoObjectsTests.cs:34`, `ToDoModel.Test/.../PeopleScoDictionaryNewTests.cs:26` and the two `TreeNodeTests*.cs` at lines 28/30 all install a `DebugTextWriter`/`tw` without asserting on it). +- Inclusion Rules: the `Console.SetOut` argument is a `StringWriter` (or other writer) created by the test, and the test method contains an assertion over `writer.ToString()`/`output.ToString()`. +- Exclusion Rules: `Console.SetOut(new DebugTextWriter())` (fire-and-forget redirect), `Console.SetOut(originalOut)`/`Console.SetOut(original)`/`Console.SetOut(this.originalOut)` (restores), commented-out calls, and `StringWriter` instances not passed to `Console.SetOut`. +- Primary Search Strategy or Query Expression: `Grep` pattern `Console\.SetOut`, path `UtilitiesCS.Test`, count mode -> 37 occurrences across 28 files; then content mode to classify each occurrence by argument. +- Primary Member Set: `StackGeek_Tests.Main_RunsSampleScenarioWithoutThrowing` (`Console.SetOut(writer)` at 153), `PrettyPrint_Tests.DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput` (196), `DASLFilterParserTests.PrintTree_WritesIndentedTreeToConsole` (109), `OlTableExtensions_Tests.EnumerateTable_WritesFormattedOutputAndMovesToStart` (1640). The remaining 33 occurrences are: 4 restores in those same tests (162, 218, 118, 1645), 3 comment mentions (StackGeek 12, PrettyPrint 17, DASL 12), 1 commented-out call (`BayesianClassifierTests_UnfinishedStubs.cs:31`), 2 in `NLogTraceWriter_Test.cs` (set 23 / restore 56, no content assertion), and 23 `new DebugTextWriter()` installs (22 files with one each, `ObsoleteBayesianClassifier_Tests.cs` with two). +- Primary Count: 4. +- Cross-check Search Strategy or Query Expression: `Grep` pattern `StringWriter` over `*.Test/**/*.cs`, then keep only files that also contain `Console.Out` and whose `StringWriter` line is adjacent to a `Console.SetOut` call. +- Cross-check Member Set: `StackGeek_Tests.cs:152`, `PrettyPrint_Tests.cs:195`, `DASLFilterParserTests.cs:107`, `OlTableExtensions_Tests.cs:1635`. Rejected `StringWriter` hits: `NonRecursiveConverter_Tests.cs:300`, `FileIO2_Tests.cs:151, 206, 277`, `EmailDataMiner_Additional_Tests.cs:473`, `ClassifierGroupUtilities_TestSupport.cs:186, 228-229`, `ClassifierGroupUtilities_Tests.cs:196`, `BayesianSerializationHelper_Tests.cs:510, 522` (that file's only `Console.SetOut`, line 31, installs a `DebugTextWriter`). +- Cross-check Count: 4. +- Member-set Comparison: the two member sets are identical after normalisation to (file, method). The count of capture-and-assert sites is 4, not 2. All four already carry `[DoNotParallelize]`. + +--- + +## Section 3 - Seam availability + +Both packages are ALREADY referenced. Exact records (`Grep Bcl\.TimeProvider|TimeProvider\.Testing` over `packages.config` and `*.csproj`): + +| Package | Version | `packages.config` | `.csproj` reference (HintPath `..\packages\.\lib\net462\...dll`) | +|---|---|---|---| +| `Microsoft.Bcl.TimeProvider` | 10.0.11 (assembly 10.0.0.11) | `UtilitiesCS/packages.config:28`, `UtilitiesCS.Test/packages.config:23`, `TaskMaster/packages.config:16`, `TaskMaster.Test/packages.config:18`, `QuickFiler/packages.config:19`, `QuickFiler.Test/packages.config:19` | `UtilitiesCS.csproj:96-97`, `UtilitiesCS.Test.csproj:602-603`, `TaskMaster.csproj:148-149`, `TaskMaster.Test.csproj:74-75`, `QuickFiler.csproj:66-67`, `QuickFiler.Test.csproj:263-264` | +| `Microsoft.Extensions.TimeProvider.Testing` | 10.9.0 | `UtilitiesCS.Test/packages.config:91`, `TaskMaster.Test/packages.config:85`, `QuickFiler.Test/packages.config:86` | `UtilitiesCS.Test.csproj:654-655`, `TaskMaster.Test.csproj:125-126`, `QuickFiler.Test.csproj:312-313` | + +In-repo precedent already using the seam on this exact call chain: + +- `TimeOutTask.TimeoutAfter(Task, int, TimeProvider?)` and `(Task, int, TimeProvider?)` (`TimeOutTask.cs:862, 949`) arm their timer with `(timeProvider ?? TimeProvider.System).CreateTimer(...)`. +- `DfDeedle.AddQfcColumnsAsync(..., TimeProvider? timeProvider = null)` (`DfDeedle.QfcColumns.cs:102-109, 127`) - the #798 shape this fix should copy. +- Tests: `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` (`using Microsoft.Extensions.Time.Testing;`, `FakeTimeProvider`, and a reusable `ArmingBarrierTimeProvider` at lines 50-85 that signals when the production code has armed a timer so a test can advance the clock only after the deadline exists), `UtilitiesCS.Test/Threading/TimeOutTask_AdditionalTests.cs:82-112` (`FakeTimeProvider` advanced past the deadline faults the proxy), `UtilitiesCS.Test/Threading/ThreadMonitorTests.cs`. + +No `packages.config` or `.csproj` change is required by this fix. `.claude/rules/csharp.md:55-63` explicitly endorses using the seam where `Microsoft.Bcl.TimeProvider` is already present. + +--- + +## Section 4 - Recommended fix design + +Production files (all in `UtilitiesCS`; all carry `#nullable enable`): + +| # | File (current length) | Method | Change | +|---|---|---|---| +| P1 | `UtilitiesCS/Extensions/DictionaryExtensions.cs` (283) | `TryAddValuesAsync` (169-180) | Delete lines 176-177; `return await Task.Run(() => dictionary.TryAddValues(key, value), token);` | +| P2 | `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` (475) | `EtlAsync` (66-130) | Add trailing `TimeProvider? timeProvider = null`; line 114 -> `.TimeoutAfter(milliseconds, timeProvider)`; pass `timeProvider` at line 98-106; drop `attempts` (82) and fix the log text (119-121) | +| P2 | same | `EtlByRowAsync` (229-263) | `int attempts` -> `TimeProvider? timeProvider`; lines 245, 259 -> `.TimeoutAfter(timeout, timeProvider)` | +| P3 | `UtilitiesCS/Extensions/DfDeedle.cs` (315) | `GetEmailDataInViewAsync` (139-219) | Add trailing `TimeProvider? timeProvider = null`; pass it at 176 (`timeProvider: timeProvider`), 180 (`timeProvider: timeProvider`), 208 (`.TimeoutAfter(1000, timeProvider)`); insert the null guard before line 186 | +| P4 (severable) | `UtilitiesCS/Extensions/DfDeedle.cs`, `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs` (276) | `GetEmailDataInView` (86-102), `FromDefaultFolder(Store, ...)` (125-148) | Replace the `TableEtlInvoker` / `StoreTableEtlInvoker` statics (62-84) with optional delegate parameters; `FromDefaultFolder(Stores, ...)` at 160 forwards the parameter | +| P5 | `UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs` (122) | `PrintTree` (97-104) | Add `TextWriter? writer = null`; write to `(writer ?? Console.Out)`; forward on recursion | +| P6 | `UtilitiesCS/HelperClasses/PrettyPrint.cs` (680, pre-existing over cap) | `PrettyPrint(DataFrame)`, `PrettyPrint(DataFrameRow)` (25, 27) | Add `TextWriter? writer = null`; zero line growth | +| P7 | `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs` (431) | `EnumerateTable` (381-428) | Add `TextWriter? writer = null`; lines 423-425 write to it | +| P8 | `UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs` (199) | `GFG.Main` (173-192) | Extract `Run(TextWriter writer)`; `Main` calls `Run(Console.Out)` | + +Test files (`UtilitiesCS.Test`; none of the files in scope carries `#nullable enable`, so nullable annotations added there are inert and `null!` is unnecessary): + +| # | File (current length) | Change | +|---|---|---| +| T1 | `Extensions/DictionaryExtensions_Tests.cs` (296) | Keep `TryAddValuesAsync_UpdatesExistingValue`; add a pre-cancelled-token test and a test proving the outer token governs cancellation | +| T2 | `Extensions/DfDeedle_COM_Tests.cs` (870, over cap; modify in place, do not grow) | `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform`: pass `timeProvider: new FakeTimeProvider()`; if P4 is adopted, replace the three static-seam swaps (399-414, 762-782, 827-847) with the delegate argument and delete their `try/finally`; update the class header (lines 20-33) | +| T3 | NEW `Extensions/DfDeedleEtlTimeoutTests.cs` | RED-first regression for AC2: strict `Table` mock whose `GetNextRow` blocks on a gate; barrier clock advanced 250 ms once the first `TimeoutAfter` timer is armed; assert `GetEmailDataInViewAsync` throws `InvalidOperationException` naming `Inbox` (fails before the fix with `NullReferenceException`); release the gate in `finally` so the orphaned `Task.Run` completes. Second test: un-advanced `FakeTimeProvider`, gate never engaged, frame has one row (the deterministic green path) | +| T4 | NEW `OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` | `EtlAsync` unit tests on the fake clock: deadline expiry returns `data == null` and cancels `tokenSource` (documents the surviving contract); un-advanced clock returns the transformed rows. Retire the `Returns(120)` tolerance at `OlTableExtensions_Tests.cs:960-963` by passing the fake clock there instead | +| T5 | `Extensions/DfDeedleQfcColumnTimeoutTests.cs` (exactly 500) | Optional pure move of the nested `ArmingBarrierTimeProvider` (lines 50-85) to NEW `TestHelpers/ArmingBarrierTimeProvider.cs` so T3 can reuse it without duplication; the file cannot otherwise grow by even one line | +| T6 | `ReusableTypeClasses/StackGeek_Tests.cs` (276), `HelperClasses/PrettyPrint_Tests.cs` (408), `OutlookObjects/Filter DASL/DASLFilterParserTests.cs` (126), `OutlookObjects/Table/OlTableExtensions_Tests.cs` (1855, over cap; modify in place) | Pass a `StringWriter` to the seamed member; delete `Console.Out` save/set/restore; remove `[DoNotParallelize]` and its comment (or rewrite the comment for `OlTableExtensions_Tests` if retained) | + +Ripple beyond `UtilitiesCS` and `UtilitiesCS.Test`: + +- `QuickFiler/Controllers/QfcDatamodel.FrameBuilding.cs:82-89` calls `GetEmailDataInViewAsync` with four positional arguments; the new optional parameter is source-compatible; `QuickFiler` recompiles in the same solution (an optional parameter is binary-breaking only for pre-compiled callers, of which there are none outside the solution). +- `TaskMaster/Ribbon/TryFunctionalityInConstruction.cs:51, 66` call `EnumerateTable()` and `df.PrettyPrint()`; source-compatible with the optional `TextWriter?`. +- `UtilitiesCS/EmailIntelligence/ClassifierGroups/Triage/Triage_OlLogic.cs:74` calls `parser.PrintTree(logicTree, 0)`; source-compatible. +- If P4 is adopted: `ToDoModel/Data Model/ID/IDList.cs:130, 226` call `FromDefaultFolder(Store, ...)`; source-compatible with an optional trailing delegate. `ToDoModel` recompiles. +- No change to `TimeOutTask.cs`, to any `packages.config`, or to any `.csproj` (new test files in a legacy non-SDK project DO require a `` item in `UtilitiesCS.Test.csproj`; that is the only project-file edit and it is a Compile item, not a package change). + +File-size budget check: `OlTableExtensions.Etl.cs` is at 475 of 500; P2 adds roughly one line per signature plus a short `` comment and removes one local, so keep the added documentation to two or three lines or delete the dead `EtlAsyncOld` (lines 132-169) in the same edit. `DfDeedle.cs` at 315 has room. `PrettyPrint.cs` (680), `TimeOutTask.cs` (1012), `DfDeedle_COM_Tests.cs` (870) and `OlTableExtensions_Tests.cs` (1855) are pre-existing violations of the 500-line cap; the plan must not add net lines to them and must say so. + +Rejected alternatives (brief): + +- Widening the 250 ms deadline or raising `GetRowCount()` in the mock: AC5 violation; rejected. +- `[DoNotParallelize]` on `DfDeedle_COM_Tests` as the AC2 fix: does not address the cause (a wall-clock deadline fires under load whether or not the class is serialized, because the sequential phase still runs under coverage instrumentation and pool contention from the test host); acceptable only as documentation of the seam clause. +- Changing `EtlAsync`'s tuple to `object[,]? data` and rethrowing the `TimeoutException`: cleaner contract but widens the diff into every `EtlAsync` consumer and the un-annotated test files; keep as a follow-up. +- `TimeProvider` seam for `TryAddValuesAsync`: preserves a deadline that has no semantic; deletion is simpler and AC1 permits it. + +--- + +## Section 5 - Risks and open questions + +1. Is `EtlAsync`'s timeout load-bearing for real Outlook COM calls? Partly. `Table.GetNextRow`/`Row.GetValues` are synchronous COM calls that can stall on a slow store, and the deadline is the only thing that returns control to the caller; but the deadline cannot stop the work (the `Task.Run` body keeps running inside the interop marshaller, exactly as the #798 comment at `DfDeedle.QfcColumns.cs:114-118` records), and its expiry today produces an NRE in `GetEmailDataInViewAsync` rather than a cancellation. The recommended design keeps the production deadline byte-for-byte (system clock when `timeProvider` is null) and only makes the test control the clock, so no production safety behaviour is removed; the guard converts the post-expiry NRE into a descriptive `InvalidOperationException` that `QfcDatamodel.FrameBuilding.cs:102-108` logs and rethrows. Whether QuickFiler should instead treat that as a cancellation (return null) is a product decision outside this fix. +2. The 250 ms-per-row budget is small for real stores as well as for tests (a one-row folder gets 250 ms for two pool hops plus COM enumeration). This research does not recommend changing it under #811 because doing so is a timing change with no deterministic test, but it should be promoted as a potential defect. +3. `GetTableInViewAsync` (`DfDeedle.cs:156`) keeps a 2000 ms system-clock window on the same test path via `new CancellationTokenSource(ms)` (`TimeOutTask.cs:52-54, 199-201`), seamed by a `Func` factory rather than a `TimeProvider`. It has eight times the margin of the 250 ms window and did not trip in the recorded failure, but it is the next-smallest deadline on the path. Options: pass a factory returning a never-cancelling `CancellationTokenSource` from the test (requires threading `timeoutSourceFactory` through `GetEmailDataInViewAsync`, a second seam type), or unify on `TimeProvider` (Microsoft.Bcl.TimeProvider ships `TimeProviderTaskExtensions.CreateCancellationTokenSource(this TimeProvider, TimeSpan)`; availability in 10.0.11 was not verified this session). Recommend recording it as a residual risk and a follow-up rather than widening this fix. +4. MSTest ordering of `[DoNotParallelize]` tests after the parallel set is asserted from documentation, not from a run in this session. If it is wrong, the four console-capture tests could still race today; the seam fix removes the dependency either way. +5. The ten-run gate uses the shell-icon exclusion locally; the unfiltered form only runs in CI. One CI run is not ten. The evidence artifact must state both facts. +6. `Workers = 0` makes the local worker count (24) differ from the CI runner's, and the local `/Settings:` scripts parallelize eight assemblies CI runs sequentially. The proposed gate omits `/Settings:` for CI parity; if the planner prefers the heavier local load as a stress test, say which one the evidence represents. +7. RS0030 is at suggestion severity, so no toolchain step will fail on a newly introduced `Thread.Sleep`/`Task.Delay`; AC5 needs an explicit diff grep in the QA loop. +8. Removing `[DoNotParallelize]` from `OlTableExtensions_Tests` (1855 lines) re-parallelizes a large COM-mock class whose only documented serialization reason is the console; if any undocumented shared state exists there, the ten-run gate is the detector. Keeping the attribute with a corrected comment is the conservative fallback. +9. The RED test in T3 depends on advancing the fake clock only after the first `TimeoutAfter` timer is armed (`FakeTimeProvider` schedules timers relative to its current time at creation); reuse the `ArmingBarrierTimeProvider` pattern or the test will hang. `DfDeedleQfcColumnTimeoutTests.cs:44-49` documents the constraint. +10. No git history was consulted (shell unavailable). Line numbers were read from the worktree at the timestamp above; the plan should re-verify them at execution time. diff --git a/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/spec.md b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/spec.md new file mode 100644 index 000000000..33b076ac5 --- /dev/null +++ b/docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/spec.md @@ -0,0 +1,333 @@ +# 2026-09-07-utilitiescs-test-determinism-780-803-594 (Spec) + +- **Issue:** #811 +- **Parent (optional):** none +- **Owner:** drmoisan +- **Last Updated:** 2026-09-07T22-40 +- **Status:** Approved +- **Version:** 0.1 + +## Context +Consolidates the `UtilitiesCS.Test` nondeterminism filed as #780, #803, and #594 into one item so the required `mstest-coverage` check stops failing on unrelated pull requests. #803 is a duplicate of the `DfDeedle_COM_Tests` `NullReferenceException` already in #594: `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` fails under parallel load because the class mutates static seams and production code at `DfDeedle.cs:186` dereferences `tableSnapshot.Item1` without a null check. #780 is `DictionaryExtensions.TryAddValuesAsync` cancelling its inner `Task.Run` after a hard-coded 500 ms wall-clock window, which the thread pool exceeds under coverage instrumentation with 24 class workers. #594 also carries two `Console.Out` races between concurrently executing tests. #592 (QuickFiler pump-host 60 s expiry) is deliberately not included: it is a different assembly and needs its own investigation. + +Correction to the paragraph above: the static-seam-pollution mechanism it attributes to `DfDeedle_COM_Tests` is refuted by the settled research. The paragraph is retained unchanged as the issue-as-filed record; the verified mechanism is stated in `## Root Cause Analysis` and derived in `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/research/root-cause.2026-09-07T22-10.md`. + +Environment: +- OS/version: Windows 11 Pro 10.0.26200 locally; GitHub Actions windows runner in CI +- Runtime: .NET Framework 4.8.1 test host, VSTest 18.9.0, MSTest 4.4.0, assembly-level parallelization with 24 class-scope workers +- Command/flags used: `vstest.console.exe /EnableCodeCoverage /InIsolation /Logger:trx "/TestCaseFilter:TestCategory!=LiveOutlook"`; the repository `mstest-coverage` job +- Data source or fixture: in-memory dictionaries; mocked `Table` and `MAPIFolder` in `DfDeedle_COM_Tests` + +Impact / Severity: +- [ ] Blocker +- [ ] High +- [x] Medium +- [ ] Low + +Medium: a nondeterministic failure in a required check blocks unrelated pull requests and erodes the signal the gate exists to provide. Production callers of `TryAddValuesAsync` may also receive a spurious cancellation under starvation. + + +## Repro & Evidence +Steps to Reproduce: +1. Run the full suite under coverage with parallel class workers. Observe, intermittently: `TryAddValuesAsync_UpdatesExistingValue` failing with `TaskCanceledException` after ~20 s (passes in ~2 ms alone); `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` failing with `NullReferenceException` at `DfDeedle.cs:186`; two `Console.Out` races. +2. Re-run the identical head commit with no change and observe all checks passing. Observed on PR #802 at `b2132349` (7107 of 7108 passed, then green on re-run) and during PR #779 verification on 2026-09-04. + +Expected: +- Every test in `UtilitiesCS.Test` passes deterministically regardless of class ordering and parallel load. +- `TryAddValuesAsync` does not fail production callers on thread-pool scheduling latency. +- Production code at `DfDeedle.cs:186` does not dereference a possibly-null snapshot element. +- A full nine-assembly `/InIsolation` run reports zero failures on ten consecutive runs. + +Actual: +Intermittent failures as above, each blocking a required check on an unrelated pull request and inviting re-run-until-green. + +Logs / Screenshots: +- [x] Attached minimal logs or screenshot +- Snippet (#780, 2026-09-04): `System.Threading.Tasks.TaskCanceledException: A task was canceled. at UtilitiesCS.DictionaryExtensions.d__10\`2.MoveNext() in UtilitiesCS\Extensions\DictionaryExtensions.cs:line 179`. Local run 4767 tests, 4766 passed, the failing test alone took 21 s. +- Snippet (#803, PR #802 run 1): `System.NullReferenceException at UtilitiesCS.DfDeedle.GetEmailDataInViewAsync, UtilitiesCS/Extensions/DfDeedle.cs line 186`, Total 7108, Passed 7107, Failed 1. + + +## Scope & Non-Goals + +- In scope: + - AC1: remove the fixed wall-clock cancellation window from `DictionaryExtensions.TryAddValuesAsync` and lock the surviving cancellation contract with tests. + - AC2: place every wall-clock deadline on the `GetEmailDataInViewAsync` ETL path under an injectable `TimeProvider`; add a descriptive guard in front of the null-snapshot dereference; replace the two ETL delegate statics on `DfDeedle` with optional parameters so `DfDeedle_COM_Tests` no longer mutates process-wide state. + - AC3: introduce a `TextWriter` seam on the four production members whose output is captured by tests, convert those tests to their own writer, and remove the `[DoNotParallelize]` serialization stopgap that currently suppresses the race. Remove the `Console.Out` save/restore in `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs` (see the count reconciliation below). + - AC4: produce ten consecutive full-suite run records as committed evidence. + - AC5: verify by inspection of the diff that no test is stabilized by a sleep, a retry, or a timing tolerance, including retirement of the existing `Returns(120)` tolerance at `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs:960-963`. + +- AC3 count reconciliation (the criterion says "two"; the tree says four): + - The criterion wording is the maintainer's and is preserved verbatim under `## Acceptance Criteria`. It does not match the current tree, and this spec records the discrepancy rather than inheriting it. + - There are FOUR capture-and-assert sites, not two: `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs` (`Main_RunsSampleScenarioWithoutThrowing`), `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs` (`DataFramePrettyHelpers_RenderRowsMarkdownAndConsoleOutput`), `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs` (`PrintTree_WritesIndentedTreeToConsole`), and `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` (`EnumerateTable_WritesFormattedOutputAndMovesToStart`). The count of four is derived twice, by two distinct search strategies over independently enumerated member sets, in the `## Numeric Derivation Evidence` section of the research artifact; both derivations return the same four (file, method) pairs. + - All four classes ALREADY carry `[DoNotParallelize]`, each with a comment naming this race. The race is therefore currently suppressed by a serialization stopgap, not eliminated. AC3 asks for elimination, so the work is to replace the stopgap with a `TextWriter` seam and then remove the attribute; removing the attribute is what makes the AC4 gate exercise the seam rather than the stopgap. + - "Two" is most plausibly the number of such failures observed in one run. That is not verifiable from the tree. + - A fifth site, `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs`, saves `Console.Out` at line 22 and restores it at line 56 with NO `[DoNotParallelize]`. It asserts nothing on console text, so it is not a victim, but it is a PROPAGATOR: under class-level parallelism its `TestInitialize` can capture a sibling class's writer and its `TestCleanup` then installs that foreign writer as the process-wide `Console.Out` for the remainder of the run. Removal of its save/restore is in scope. + +- Out of scope / non-goals: + - The roughly 24 further test classes that call `Console.SetOut(new DebugTextWriter())` with no restore. These are aggressors, not victims: none asserts on console content. Once no test captures `Console.Out`, they harm nothing, and touching approximately 24 files across five test projects would be a disproportionate blast radius for a bugfix. + - Issue #592 (QuickFiler pump-host 60 s expiry). Different assembly; needs its own investigation. + - Changing the 250 ms-per-row ETL budget at OlTableExtensions.Etl.cs line 81. Altering it is a timing change with no deterministic test; recorded as a follow-up. + - The 2000 ms `GetTableInViewAsync` window reached from DfDeedle.cs line 156. It is seamed by a `Func` factory rather than a `TimeProvider`, so threading it would add a second seam type; recorded as a residual risk and a follow-up. + - Deleting the inert `(int, int)` `TimeoutAfter` overloads from UtilitiesCS/Threading/TimeOutTask.cs. That file is 1012 lines (already over the 500-line cap) and the overloads retain two test callers and one dead-code caller; recorded as a follow-up. + - Changing `EtlAsync`'s tuple contract to `object[,]? data` and rethrowing the `TimeoutException`. Cleaner, but widens the diff into every `EtlAsync` consumer; recorded as a follow-up. + +- Explicitly excluded systems, integrations, or datasets: + - No live Outlook or COM interaction. All tests use mocked `Table`, `MAPIFolder`, and `Explorer` objects; the `TestCategory!=LiveOutlook` filter remains in force. + - No package additions or version changes. `Microsoft.Bcl.TimeProvider 10.0.11` and `Microsoft.Extensions.TimeProvider.Testing 10.9.0` are already referenced by the affected projects. + - No CI workflow change. The `mstest-coverage` job is unmodified. + +## Root Cause Analysis + +The mechanism recorded in the issue Summary is refuted. The verified mechanism below is established by static reading of the worktree at `04a54e681bd21e841e124c016df30672ee701b75` and documented in `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/research/root-cause.2026-09-07T22-10.md`, Section 1 (claims 1 to 5 and 7 confirmed; claim 6, the millisecond timings, is only partially confirmed because no TRX from the failing run exists in the tree). No build and no test run were possible in the research session, so every claim below is a static-reading claim and the line numbers should be re-verified at execution time. + +### What the issue Summary got wrong + +The Summary states that `DfDeedle_COM_Tests` "mutates static seams" and that this "races other classes". That is refuted on two independent grounds: + +1. All seven static-seam writes in `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` save the prior value and restore it in a `finally` block: `MessageBoxInvoker` at lines 197-211, 222-236, 261-281, 317-334; `TableEtlInvoker` at lines 399-414; `StoreTableEtlInvoker` at lines 762-782 and 827-847. +2. None of the seams is read on the async path. `TableEtlInvoker` is read at exactly one production site, `DfDeedle.cs:94`, inside the SYNCHRONOUS `GetEmailDataInView(Explorer)`. `StoreTableEtlInvoker` is read at `DfDeedle.FrameUtilities.cs:143`, inside `FromDefaultFolder(Store, ...)`. `MessageBoxInvoker` is reachable in principle from `AddQfcColumnsAsync`, but only when the folder lacks the `Triage` user-defined property; the failing test builds the folder with `BuildFolderWithUdp("Triage")`, so no dialog seam is invoked. + +The failing test itself writes no seam at all. The described race therefore has no second participant. + +Two further facts reinforce this. The assembly runs at `ExecutionScope.ClassLevel` (`UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21`), so tests within `DfDeedle_COM_Tests` execute sequentially on one worker. The only other test caller of the affected `DfDeedle` entry points is `DfDeedleQfcColumnTimeoutTests`, which is itself `[DoNotParallelize]`. + +### The verified chain for the `NullReferenceException` (AC2, issues #803 and #594 item 1) + +1. `OlTableExtensions.Etl.cs:81` sets `int milliseconds = 250 * rowCount`. The failing test's strict mock returns `GetRowCount() == 1`, so the deadline is 250 ms. +2. Branch selection at `Etl.cs:90-107` takes the `EtlByRowAsync` branch, because `MAPIFields.BinaryToStringFields` contains `ConversationId` and the test's column dictionary contains that column. +3. `EtlByRowAsync` awaits two `Task.Run(...).TimeoutAfter(timeout, attempts)` hops (`Etl.cs:244-245` and `248-259`). Each hop is a separate 250 ms wall-clock deadline that starts when `TimeoutAfter` is called, that is, BEFORE the thread pool has scheduled the `Task.Run` body. The deadline therefore measures scheduling latency in addition to the work. +4. The retry count is inert. The `(int, int)` overload at `TimeOutTask.cs:824` wraps a call to the proxy-returning `(int, TimeProvider?)` overload in a `catch (TimeoutException)`. The inner overload never throws: it returns the task itself, or a proxy `tcs.Task` that a `TimeProvider` timer later faults with `TrySetException(new TimeoutException())`. The `catch` can never fire, `repeatAttempts` is never consulted, and the "attempts remaining" log line is unreachable. +5. On expiry the `TimeoutException` propagates out of `EtlByRowAsync` into `Etl.cs:117-123`, which logs and calls `tokenSource.Cancel()` but swallows the exception. `data` remains null (initialised `object[,]? data = null` at line 83), and line 129 returns `(data!, columnDictionary)`, forcing a null through a null-forgiving suppression into a non-nullable tuple element. In the failing test the `tokenSource` is a fresh source unrelated to the `CancellationToken.None` passed as `token`, so nothing downstream observes the cancellation. +6. `DfDeedle.cs:188` then dereferences `tableSnapshot.Item1.GetLength(0)` inside a `LogDfTiming` call whose statement begins at line 186, which is the sequence point the CI stack trace reports. +7. The issue-#798 guard at `DfDeedle.cs:195` runs AFTER that dereference and inspects `Item2` (the column map) only, never `Item1`. Hoisting it would not guard this path. + +This is a production defect, not only a test defect. In production, `QuickFiler/Controllers/QfcDatamodel.FrameBuilding.cs:82-109` wraps the call in `catch (TaskCanceledException)` and `catch (System.Exception)`; because the dereference fires before the cancelled token is next consulted, a real Outlook ETL timeout surfaces as an unattributed `NullReferenceException` rather than the intended cancellation. + +The failing-run millisecond figures quoted in #803 (99 ms and 96 ms green, 389 ms failing, against a 250 ms deadline) are not verifiable from the tree. What the code does establish is that the path performs at least four thread-pool scheduling hops plus a progress timer, under 24 class-level workers and coverage instrumentation, and that nothing else on the path has a deadline smaller than 250 ms (the next smallest is 1000 ms at `DfDeedle.cs:208`). + +### The verified chain for the `TaskCanceledException` (AC1, issue #780) + +`DictionaryExtensions.TryAddValuesAsync` (`UtilitiesCS/Extensions/DictionaryExtensions.cs:169-180`) creates a linked token source and calls `CancelAfter(500)` at line 177, then passes the linked token to `Task.Run`. + +- `TryAddValuesAsync` has ZERO production call sites. The only invocation anywhere in the repository is `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs:244`; the other two search hits are the definition and a test method name. +- The body it guards, `TryAddValues` at `DictionaryExtensions.cs:123-136`, is a bounded compare-and-swap loop over `ConcurrentDictionary.TryUpdate` that terminates; it performs no I/O and cannot hang. +- The linked token is passed only to `Task.Run`, and a `Task.Run` body cannot be interrupted by its token once running. The window can therefore only cancel work that has not started. + +The 500 ms window consequently guards nothing and can only convert thread-pool scheduling latency into a spurious `TaskCanceledException` at the `await`, which is exactly the failure recorded at line 179. Secondary defect: `linkedTS` is never disposed, so every call leaks a timer until it fires. + +### The `Console.Out` races (AC3, issue #594 items 2 and 3) + +Class A saves `originalOut` and installs `writer_A`; class B on another worker saves `originalOut` (now `writer_A`) and installs `writer_B`; A's `finally` restores the real console, detaching `writer_B`; B's production call writes to the console and `writer_B.ToString()` is empty, so B's assertion fails. Four victims and roughly 25 aggressors exist in `UtilitiesCS.Test`. All four victims currently carry `[DoNotParallelize]`; under MSTest's documented execution model those run sequentially after the parallel set, so as of this tree no victim can overlap an aggressor. That ordering claim is documentation-sourced and was not confirmed by a run. + +Superseded issues: #780, #803, #594 (close with a pointer to this issue). + + +## Proposed Fix + +### Design summary (what changes where): + +Three independent defects, one shared technique. Where a wall-clock deadline is load-bearing in production, it is placed under an injectable `TimeProvider` so tests control the clock without widening the deadline. Where a wall-clock deadline is not load-bearing, it is deleted. Where a test depends on process-wide `Console.Out`, the production member gains a `TextWriter` seam so the test supplies its own writer. + +- AC1: delete the linked token source and `CancelAfter(500)`; pass the caller's `token` straight to `Task.Run`. +- AC2: thread an optional `TimeProvider?` through `GetEmailDataInViewAsync`, `EtlAsync`, and `EtlByRowAsync`; replace the three inert `(int, int)` `TimeoutAfter` calls on that path with the `(int, TimeProvider?)` overload; insert a null guard on `tableSnapshot.Item1` before the `LogDfTiming` dereference; replace the `TableEtlInvoker` and `StoreTableEtlInvoker` statics with optional delegate parameters and delete the statics. +- AC3: add an optional `TextWriter?` to `DASLFilterParser.PrintTree`, the two `PrettyPrinters.PrettyPrint` overloads, and `OlTableExtensions.EnumerateTable`; extract `GFG.Main`'s body into `Run(TextWriter)`. Convert the four capture-and-assert tests to their own `StringWriter`, remove their `Console.Out` handling and `[DoNotParallelize]`, and remove the `NLogTraceWriter_Test` save/restore. + +The scoping decision is settled and is not re-opened here. In particular, replacing the `DfDeedle` static seams with optional delegate parameters is IN SCOPE, because AC2's first clause literally requires that `DfDeedle_COM_Tests` stop mutating process-wide static seams. `MessageBoxInvoker` remains static: it is a modal-dialog stub reached through private methods that tests invoke by reflection with fixed argument arrays, so parameterising it would rewrite five tests for no determinism gain. Its restored-in-`finally`, reader-free status is documented in the test-class header instead. + +### Boundaries and invariants to preserve: + +- **Production timing behaviour is unchanged.** Every new `timeProvider` parameter defaults to `null`, which resolves to `TimeProvider.System`. The production deadline is preserved exactly; only the test controls the clock. This holds byte-for-byte because the `(int, int)` overload already delegated to the `(int, TimeProvider? = null)` overload and its retry was inert. +- **Every new parameter is optional and trailing, so all existing callers stay source-compatible.** The callers that must continue to compile unchanged are `QuickFiler/Controllers/QfcDatamodel.FrameBuilding.cs:82-89`, `TaskMaster/Ribbon/TryFunctionalityInConstruction.cs:51,66`, `UtilitiesCS/EmailIntelligence/ClassifierGroups/Triage/Triage_OlLogic.cs:74`, and `ToDoModel/Data Model/ID/IDList.cs:130,226`. An optional parameter is binary-breaking only for pre-compiled callers, of which there are none outside the solution. +- **Cancellation semantics of `TryAddValuesAsync` are preserved.** A pre-cancelled caller token still yields `TaskCanceledException` from `Task.Run`; only the internal timer is removed. +- **The 500-line file cap.** `PrettyPrint.cs` (680), `TimeOutTask.cs` (1012), `DfDeedle_COM_Tests.cs` (870), and `OlTableExtensions_Tests.cs` (1855) are PRE-EXISTING violations; the fix must not add net lines to any of them. `OlTableExtensions.Etl.cs` is at 475 of 500 and has little room, so added documentation must stay to two or three lines or the dead `EtlAsyncOld` (lines 132-169) must be deleted in the same edit. `DfDeedleQfcColumnTimeoutTests.cs` is at exactly 500 and cannot grow by one line, so the reusable `ArmingBarrierTimeProvider` must move out of it rather than be copied. +- **AC5 constrains the technique, not just the outcome.** No sleep, no retry loop, no widened deadline, and no mock value chosen to outrun a deadline may be introduced or retained in the touched tests. + +### Dependencies or blocked work: + +None. The `TimeProvider` seam is already in place end-to-end: `TimeOutTask.TimeoutAfter` already accepts a `TimeProvider?`, `DfDeedle.AddQfcColumnsAsync` already threads one (issue #798), and both packages are already referenced. No blocking upstream work. + +### Implementation strategy (what changes, not sequencing): + +#### Files/modules to change: + +Production (`UtilitiesCS`, eight files; all carry `#nullable enable`): + +1. `UtilitiesCS/Extensions/DictionaryExtensions.cs` — `TryAddValuesAsync`: delete the linked source and `CancelAfter(500)`. +2. `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` — `EtlAsync` and `EtlByRowAsync`: add trailing `TimeProvider? timeProvider = null`; convert the three `TimeoutAfter(ms, attempts)` calls to `TimeoutAfter(ms, timeProvider)`; drop the inert `attempts` local; correct the timeout log text, which currently claims "timed out {attempts} times" for a retry that never happened, and drop the banned `DateTime.Now` on that line. +3. `UtilitiesCS/Extensions/DfDeedle.cs` — `GetEmailDataInViewAsync`: add trailing `TimeProvider? timeProvider = null`; forward it to `AddQfcColumnsAsync` and `EtlAsync`; convert `.TimeoutAfter(1000, 2)` to `.TimeoutAfter(1000, timeProvider)`; insert the null-snapshot guard before the `LogDfTiming` call. `GetEmailDataInView(Explorer)`: replace the `TableEtlInvoker` static with an optional delegate parameter and delete the static. +4. `UtilitiesCS/Extensions/DfDeedle.FrameUtilities.cs` — `FromDefaultFolder(Store, ...)`: replace the `StoreTableEtlInvoker` static with an optional delegate parameter; the `FromDefaultFolder(Stores, ...)` overload forwards it. +5. `UtilitiesCS/OutlookObjects/Filter DASL/DASLFilterParser.cs` — `PrintTree`: add `TextWriter? writer = null`; write to `(writer ?? Console.Out)`; forward on recursion. +6. `UtilitiesCS/HelperClasses/PrettyPrint.cs` — the `PrettyPrint(DataFrame)` and `PrettyPrint(DataFrameRow)` overloads: add `TextWriter? writer = null`, with zero net line growth. +7. `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs` — `EnumerateTable`: add `TextWriter? writer = null` and route its three `Console.WriteLine` calls to it. +8. `UtilitiesCS/ReusableTypeClasses/Other/StackGeek.cs` — extract `GFG.Main`'s body into `public static void Run(TextWriter writer)`; `Main` calls `Run(Console.Out)`. + +Tests (`UtilitiesCS.Test`, eleven files; none of these carries `#nullable enable`, so annotations added there are inert): + +9. `UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs` +10. `UtilitiesCS.Test/Extensions/DfDeedle_COM_Tests.cs` (870 lines, over cap; modify in place) +11. `UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs` (new) +12. `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs` (new) +13. `UtilitiesCS.Test/Extensions/DfDeedleQfcColumnTimeoutTests.cs` (exactly 500 lines; pure move of the nested `ArmingBarrierTimeProvider` out of it) +14. `UtilitiesCS.Test/TestHelpers/ArmingBarrierTimeProvider.cs` (new; receives the moved helper) +15. `UtilitiesCS.Test/ReusableTypeClasses/StackGeek_Tests.cs` +16. `UtilitiesCS.Test/HelperClasses/PrettyPrint_Tests.cs` +17. `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs` +18. `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` (1855 lines, over cap; modify in place) +19. `UtilitiesCS.Test/HelperClasses/NLogTraceWriter_Test.cs` (remove the `Console.Out` save/restore) + +Project file (one edit): + +20. `UtilitiesCS.Test/UtilitiesCS.Test.csproj` — add `` items for the three new test files. These are legacy non-SDK `packages.config` projects, so a new `.cs` file that is not listed is silently not compiled. This is a Compile-item edit only; no package change is required, because `Microsoft.Bcl.TimeProvider 10.0.11` and `Microsoft.Extensions.TimeProvider.Testing 10.9.0` are already referenced by both `UtilitiesCS` and `UtilitiesCS.Test`. + +Files cited elsewhere in this document that do not appear in items 1 to 20 are references only and are not modified. + +#### Functions/classes/CLI commands impacted: + +`DictionaryExtensions.TryAddValuesAsync`; `OlTableExtensions.EtlAsync`; `OlTableExtensions.EtlByRowAsync`; `OlTableExtensions.EnumerateTable`; `DfDeedle.GetEmailDataInViewAsync`; `DfDeedle.GetEmailDataInView(Explorer)`; `DfDeedle.FromDefaultFolder(Store, ...)` and its `Stores` overload; `DfDeedle.TableEtlInvoker` and `DfDeedle.StoreTableEtlInvoker` (deleted); `DASLFilterParser.PrintTree`; `PrettyPrinters.PrettyPrint(DataFrame)` and `PrettyPrint(DataFrameRow)`; `GFG.Main` and the new `GFG.Run`. No CLI command changes. + +#### Data flow and validation changes: + +The ETL result tuple keeps its shape `(object[,] data, Dictionary columnInfo)`. The change is that `GetEmailDataInViewAsync` now validates `tableSnapshot.data` before use instead of dereferencing it. The guard is placed ahead of the `LogDfTiming` call and therefore ahead of the existing `ValidateRequiredEmailColumns` guard, which inspects the column map only. A null test against a non-nullable tuple element compiles without a nullable diagnostic under `#nullable enable`. + +`EtlAsync`'s own contract is deliberately unchanged: on deadline expiry it still swallows the `TimeoutException`, still cancels `tokenSource`, and still returns a null `data`. Changing that contract is recorded as a follow-up, not part of this fix. + +#### Error handling and logging updates: + +- New: `GetEmailDataInViewAsync` throws `InvalidOperationException` naming the folder when the snapshot data is null, in the wording style of the existing `ValidateRequiredEmailColumns` message. +- Corrected: the `EtlAsync` timeout log no longer claims a retry count that was never consumed, and no longer formats the timestamp with the banned `DateTime.Now`. +- Unchanged: `QfcDatamodel.FrameBuilding.cs:102-108` continues to log and rethrow; it now receives an attributed exception instead of an unattributed `NullReferenceException`. + +#### Rollback/feature-flag considerations (if applicable): + +No feature flag. Every production change is either a deletion of a guard with no consumer, an optional parameter whose default reproduces current behaviour, or a new exception on a path that previously threw `NullReferenceException` at the next statement. Rollback is a straight revert of the branch. + +### Technical specifications (interfaces/contracts): + +#### Inputs/outputs and formats: + +- `TryAddValuesAsync(this ConcurrentDictionary, TKey, TValue, CancellationToken)` — signature unchanged; returns `bool`. Cancellation is now governed solely by the caller's token. +- `EtlAsync(..., TimeProvider? timeProvider = null)` and `EtlByRowAsync(..., TimeProvider? timeProvider)` — return type unchanged. +- `GetEmailDataInViewAsync(Explorer, CancellationToken, CancellationTokenSource, ProgressTracker, TimeProvider? timeProvider = null)` — returns `Frame`; now throws `InvalidOperationException` instead of `NullReferenceException` on a null snapshot. +- `GetEmailDataInView(Explorer, Func columnInfo)>? etl = null)` and `FromDefaultFolder(Store, ..., = null)` — replaces the static invoker properties. +- `PrintTree(TreeNode, int, TextWriter? writer = null)`, `PrettyPrint(this DataFrame, TextWriter? writer = null)`, `PrettyPrint(this DataFrameRow, TextWriter? writer = null)`, `EnumerateTable(this Outlook.Table, TextWriter? writer = null)` — `null` means `Console.Out`, preserving current behaviour for all existing callers. + +#### Required configuration keys and defaults: + +None. No configuration key, `.runsettings` value, `.editorconfig` entry, or `packages.config` entry is added or changed. + +#### Backward-compatibility expectations: + +Source-compatible for every in-solution caller; all new parameters are optional and trailing. The two deleted static properties (`TableEtlInvoker`, `StoreTableEtlInvoker`) are a public-surface removal, but their only readers and writers are in-repo and all are updated in the same change. The one intentional behaviour change is the exception type on the null-snapshot path, described under `## Data / API / Config Impact`. + +#### Performance constraints (latency/throughput/memory): + +No production latency change. All deadlines resolve to `TimeProvider.System` when no provider is supplied. One small improvement: `TryAddValuesAsync` no longer allocates a linked `CancellationTokenSource` and an undisposed timer per call. + + +## Assumptions, Constraints, Dependencies +- Assumptions (environment, data, access): + - The research artifact's line numbers were read statically at `04a54e681bd21e841e124c016df30672ee701b75` with no build and no test run; the executor re-verifies them before editing. + - MSTest runs `[DoNotParallelize]` tests sequentially after the parallel set. This is documentation-sourced and was not confirmed by a run; the AC3 seam fix removes the dependency on it either way. + - `Workers = 0` resolves to 24 on the local workstation. The GitHub-hosted runner's core count is not verifiable from the tree; both environments have reproduced the failures. + - Local full-suite runs require the shell-icon exclusion filter (`ShellUtilities`, `SysImageListHelper`, `OSBrowser`), per the `SHELL_ICON_EXCLUSION: REQUIRED` verdict recorded in the #798 baseline evidence. CI runs those classes unfiltered. +- Constraints (budget, performance, compatibility): + - The 500-line file cap, with the pre-existing violations enumerated under "Boundaries and invariants to preserve". + - AC5 forbids sleeps, retries, and timing tolerances, including in the RED-first regression test. + - No production timing behaviour may change. + - The CI `mstest-coverage` job carries `timeout-minutes: 30`, which bounds what can be asked of CI. +- External dependencies (services, libraries, releases): + - `Microsoft.Bcl.TimeProvider 10.0.11` and `Microsoft.Extensions.TimeProvider.Testing 10.9.0`, both already referenced. No new dependency. + +## Data / API / Config Impact +- User-facing or API changes: + - **Intentional diagnostic improvement.** A swallowed ETL timeout on the QuickFiler frame-building path currently surfaces as a `NullReferenceException` that names neither the folder nor the step. After the fix it surfaces as an `InvalidOperationException` naming the folder and stating that the table ETL timed out or was cancelled before returning rows. `QfcDatamodel.FrameBuilding.cs:102-108` logs and rethrows it, so the user-visible failure text changes and the log entry becomes attributable. The failure still occurs; only its diagnosis improves. Whether QuickFiler should instead treat this as a cancellation and return null quietly is a product decision outside this fix. + - Removal of the public static properties `DfDeedle.TableEtlInvoker` and `DfDeedle.StoreTableEtlInvoker`, replaced by optional delegate parameters. All in-repo readers and writers are updated in the same change. + - Optional trailing parameters added to seven public members, all source-compatible. +- Data or migration considerations: none. No persisted format, schema, or settings file is touched. +- Logging/telemetry updates (if any): the `EtlAsync` timeout log message is corrected to stop reporting a retry count that is never consumed, and stops using `DateTime.Now`. The new `InvalidOperationException` message is the diagnostic carrier for the null-snapshot path. +- Compatibility notes (CLI flags, config schemas, versioning): none. No CLI flag, `.runsettings`, or workflow change. + +## Test Strategy +Seeded from issue: the acceptance criteria for this bug are maintained in a single place, `## Acceptance Criteria` below. Work mode is `full-bug`, so `spec.md` is the sole acceptance-criteria source file and no `user-story.md` exists. This section describes how each criterion is validated; it does not restate the criteria. + +Validation: + +- Unit coverage areas: `DictionaryExtensions.TryAddValuesAsync` cancellation contract; `DfDeedle.GetEmailDataInViewAsync` null-snapshot path under a controlled clock; `OlTableExtensions.EtlAsync` deadline-expiry and green paths under a controlled clock; the four seamed `TextWriter` members. +- Integration scenario to retest: ten consecutive full-suite runs locally and one CI run. +- Manual verification notes: none. + +- Regression tests to add or update: + - **AC2 requires a RED-first regression test.** New `UtilitiesCS.Test/Extensions/DfDeedleEtlTimeoutTests.cs`: a strict `Table` mock whose `GetNextRow` blocks on a gate, with the clock advanced past the 250 ms deadline; the test asserts that `GetEmailDataInViewAsync` throws `InvalidOperationException` naming the folder. It must FAIL before the fix with `NullReferenceException` and PASS after with the descriptive `InvalidOperationException`. The gate is released in a `finally` so the orphaned `Task.Run` completes. A second test uses an un-advanced fake clock, never engages the gate, and asserts the one-row frame — the deterministic green path. + - **`ArmingBarrierTimeProvider` is a hard constraint, not a convenience** (research risk 9). `FakeTimeProvider` schedules timers relative to its current time at creation, so the clock must be advanced only AFTER the first `TimeoutAfter` timer is armed. Advancing earlier means the timer is armed past an already-elapsed deadline and the test hangs. The existing pattern is at `DfDeedleQfcColumnTimeoutTests.cs:50-85` and is moved to a shared helper rather than duplicated, because that file is at exactly the 500-line cap. + - New `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensionsEtlClockTests.cs`: `EtlAsync` on the fake clock — expiry returns `data == null` and cancels the token source (documenting the surviving contract), and an un-advanced clock returns the transformed rows. + - `DfDeedle_COM_Tests.GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform`: pass an un-advanced `FakeTimeProvider`, so every deadline on the path is armed on a clock that does not move and cannot fire regardless of host load. The assertions are unchanged. This is not a timing tolerance: the deadline is not widened, it is placed under the test's control. +- Unit tests (MSTest) for the fixed behavior and boundaries: + - `TryAddValuesAsync_UpdatesExistingValue` is retained and becomes deterministic with no change to the test. + - New: a pre-cancelled token yields `TaskCanceledException`, locking the surviving cancellation contract. + - New: a token cancelled after the work is observed to start proves the outer token, and not an internal timer, governs cancellation. Note that no test can be made to fail deterministically before the AC1 fix without a sleep, because the original failure is load-dependent; the RED artifact for AC1 is therefore the contract-shape test, not a reproduction. + - The four seamed `TextWriter` members are asserted against a test-owned `StringWriter` with no `Console.Out` interaction. +- Edge cases and negative scenarios (invalid inputs, missing data, boundary values): deadline expiry on the first ETL hop and on the second; a null snapshot with a valid column map (proving the new guard fires before the existing `ValidateRequiredEmailColumns`); a pre-cancelled token; a null `TextWriter` argument resolving to `Console.Out`; a null `TimeProvider` argument resolving to `TimeProvider.System`. +- Error handling and logging verification: the `InvalidOperationException` message names the folder; the exception type is asserted explicitly, so a regression to `NullReferenceException` fails the test rather than passing a generic `Should().Throw()`. +- Coverage impact and targets for changed lines/modules: repository-wide line coverage must remain at or above the current floor and must not regress on changed lines. The new guard, the new `Run(TextWriter)` extraction, and every new optional-parameter branch (supplied and defaulted) are exercised, so changed-line coverage on the eight production files is expected to be complete. +- Toolchain commands to run (format → lint → type-check → test), verbatim from `CLAUDE.md`, restarting from step 1 if any step fails or changes files: + 1. `dotnet tool run csharpier format .` (verify: `dotnet tool run csharpier check .`) + 2. `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` + 3. `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` + 4. `vstest.console.exe /EnableCodeCoverage` +- AC4 gate, stated honestly: + - Ten consecutive full-suite `/InIsolation` runs over the assembly set the `mstest-coverage` workflow discovers (every `*.Test.dll` under `\bin\Debug\`, excluding `\obj\` and `\ref\`; the set is enumerated in the research artifact, Section 2, AC4), with `TestCategory!=LiveOutlook`. + - Estimated 10 to 20 minutes total, plus one build. The #798 baseline measured 54.5 s per run without in-proc coverage; `/EnableCodeCoverage` adds instrumentation overhead that has not been measured for the full set in any committed evidence. + - Local runs must extend the filter with the shell-icon exclusion (`FullyQualifiedName!~ShellUtilities&FullyQualifiedName!~SysImageListHelper&FullyQualifiedName!~OSBrowser`), because one of those tests fails per run on this workstation with `Win32 handle that was passed to Icon is not valid`, independently of this fix. The unfiltered form is covered by the CI run on the pull request. One CI run is not ten; the evidence artifact must state both facts. + - The gate omits `/Settings:` so parallelism comes from the assembly attribute exactly as in CI. Assembly discovery from a worktree under `.claude/worktrees/` must apply the dot-claude exclusion relative to the worktree root, or discovery returns zero assemblies and the gate is vacuous. + - The CI `mstest-coverage` job carries `timeout-minutes: 30` in `.github/workflows/_mstest-coverage.yml`, so the ten-run gate cannot be a CI gate and must be produced as local evidence. + - Result recording: a ten-row table (run, total, passed, failed, wall-clock) read from the `` element of each TRX, written to `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/evidence/regression-testing/`. Raw TRX and `.coverage` files are not committed: the TRX embeds `computerName` and absolute paths, and `.coverage` files are large binaries under a gitignored directory. +- AC5 enforcement, stated honestly: + - RS0030 is held at `suggestion` severity in `.editorconfig`, so NO toolchain step fails on a newly introduced `Thread.Sleep` or `Task.Delay`. `CancelAfter`, `TimeoutAfter`, and `WaitOne` are not in `BannedSymbols.txt` at all. + - AC5 therefore requires an explicit search over the diff during the QA loop for `Thread.Sleep`, `Task.Delay`, `CancelAfter`, `WaitOne`, `new CancellationTokenSource()`, retry loops, and mock values chosen to outrun a deadline. Reliance on the analyzer is not sufficient. + - The existing tolerance at `OlTableExtensions_Tests.cs:960-963` (`GetRowCount()` returning 120 with the comment "so the timeout cannot fire under test-host contention") is retired in this change and replaced with the fake clock. +- Manual validation steps (if required): none beyond the AC4 evidence run. + + +## Acceptance Criteria +- [x] AC1: `TryAddValuesAsync` no longer cancels on a fixed wall-clock window, or the window is driven by an injected `TimeProvider`; the test passes deterministically under 24-worker parallel coverage runs. +- [x] AC2: `DfDeedle_COM_Tests` no longer mutates process-wide static seams in a way another class can observe, and `DfDeedle.cs:186` guards the null snapshot element with a descriptive failure. +- [x] AC3: The two `Console.Out` races are removed by eliminating the shared-console dependency. +- [ ] AC4: A full nine-assembly `/InIsolation` run with `TestCategory!=LiveOutlook` reports zero failures on ten consecutive runs, recorded as evidence. +- [x] AC5: No test is stabilized by a sleep, a retry, or a timing tolerance. + +## Risks & Mitigations +- Technical or operational risks: + 1. **The ETL timeout is partially load-bearing for real COM calls.** `Table.GetNextRow` and `Row.GetValues` are synchronous COM calls that can stall on a slow store, and the deadline is the only thing that returns control to the caller. It is only partially load-bearing because the deadline cannot stop the work: the `Task.Run` body keeps running inside the interop marshaller, exactly as the #798 comment at `DfDeedle.QfcColumns.cs:114-118` records. + 2. **The residual 2000 ms `GetTableInViewAsync` window** reached from DfDeedle.cs line 156 stays on the system clock. It has eight times the margin of the 250 ms window and did not trip in the recorded failure, but it is the next-smallest deadline on the same test path. It is seamed by a `Func` factory rather than a `TimeProvider`, so covering it would introduce a second seam type into this fix. + 3. **The `[DoNotParallelize]` ordering claim is documentation-sourced, not run-verified.** If MSTest does not in fact run those tests after the parallel set, the four console-capture tests can still race today. + 4. **Re-parallelizing `OlTableExtensions_Tests`** removes `[DoNotParallelize]` from a 1855-line COM-mock class whose only documented serialization reason is the console. If undocumented shared state exists in that class, removing the attribute could introduce a new intermittent failure. + 5. **The 250 ms-per-row budget is small for real stores as well as for tests.** A one-row folder gets 250 ms for two pool hops plus COM enumeration. + 6. **`Workers = 0` makes the local worker count differ from the runner's**, and the local `Invoke-MSTest*.ps1` scripts pass a `/Settings:` file that parallelizes eight assemblies CI runs sequentially. Local and CI loads are not equivalent. + 7. **The RED test can hang** if the fake clock is advanced before the first `TimeoutAfter` timer is armed. + 8. **AC5 has no automated enforcement**, because RS0030 is at `suggestion` severity and the relevant timing APIs are largely unbanned. + +- Mitigations and rollbacks: + 1. The design keeps the production deadline byte-for-byte (system clock when `timeProvider` is null) and only makes the test control the clock, so no production safety behaviour is removed. The new guard converts the post-expiry `NullReferenceException` into a descriptive `InvalidOperationException` that the QuickFiler boundary logs and rethrows, which is strictly better diagnosis than today. + 2. Record it as a residual risk and a follow-up rather than widening this fix. Two options exist for the follow-up: pass a factory returning a never-cancelling `CancellationTokenSource` from the test, or unify on `TimeProvider` via `TimeProviderTaskExtensions.CreateCancellationTokenSource` (its availability in `Microsoft.Bcl.TimeProvider 10.0.11` was not verified). + 3. The `TextWriter` seam removes the dependency on the ordering claim either way, which is why the seam and not the attribute is the fix. + 4. The AC4 ten-run gate is the detector. Conservative fallback: keep `[DoNotParallelize]` on `OlTableExtensions_Tests` and rewrite its comment so it no longer claims the console as the reason, since after the seam that reason is false. The class is still converted to the `TextWriter` seam either way. + 5. Not changed under this issue: altering it is a timing change with no deterministic test. Promoted as a potential defect for separate triage. + 6. The proposed gate omits `/Settings:` for CI parity, and the evidence artifact states which load it represents. + 7. Reuse the `ArmingBarrierTimeProvider` pattern documented at `DfDeedleQfcColumnTimeoutTests.cs:44-49`, moved to a shared helper. Release the blocking gate in a `finally` so no orphaned `Task.Run` outlives the test. + 8. An explicit diff search in the QA loop, listed under `## Test Strategy`, with the result recorded in the QA evidence. + - Rollback: no feature flag is warranted. Every production change is a deletion of a consumer-free guard, an optional parameter whose default reproduces current behaviour, or a new exception on a path that previously threw at the next statement. Rollback is a straight revert of the branch. + +## Rollout & Follow-up +- Release/rollout steps: + 1. Implement, then run the full toolchain in the order given under `## Test Strategy` until it passes in a single pass. + 2. Produce the AC4 ten-run evidence locally and commit the summary table (not the raw TRX or `.coverage` files). + 3. Open the pull request; the `mstest-coverage` CI run supplies the unfiltered single-run form. +- Post-fix monitoring or clean-up tasks: + - Close #780, #803, and #594 with a pointer to #811. + - Watch the next several `mstest-coverage` runs on unrelated pull requests for a recurrence of any of the three failure modes. + - File follow-ups for: the 250 ms-per-row ETL budget; the residual 2000 ms `GetTableInViewAsync` window; deletion of the inert `(int, int)` `TimeoutAfter` overloads; changing `EtlAsync`'s tuple to a nullable `data` with a rethrown `TimeoutException`; the roughly 24 unrestored `DebugTextWriter` console aggressors; the two production `Console.WriteLine` diagnostics in `OlTableExtensions.TableAccess.cs` that should use the logger; and promotion of RS0030 from `suggestion` to `warning` after legacy cleanup. + - Issue #592 (QuickFiler pump-host 60 s expiry) remains open and separate. +- Links: issue, PRs, related docs + - Issue: https://github.com/drmoisan/TaskMaster/issues/811 + - Superseded: #780, #803, #594. Related and not included: #592. Prior related work: #798 (the `TimeProvider` shape this fix copies), #520 (the original console-race report), #181 (banned-symbol rollout). + - Research: `docs/features/active/2026-09-07-utilitiescs-test-determinism-780-803-594-811/research/root-cause.2026-09-07T22-10.md` diff --git a/docs/features/potential/2026-09-08-console-out-aggressors-and-banned-symbol-promotion.md b/docs/features/potential/2026-09-08-console-out-aggressors-and-banned-symbol-promotion.md new file mode 100644 index 000000000..4827b3c20 --- /dev/null +++ b/docs/features/potential/2026-09-08-console-out-aggressors-and-banned-symbol-promotion.md @@ -0,0 +1,100 @@ +# console-out-aggressors-and-banned-symbol-promotion + +- Date captured: 2026-09-08 +- Author: Dan Moisan +- Status: Draft + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +## Summary + +Residual process-wide console mutation and analyzer-severity work left in place by issue #811. +#811 eliminated the four `Console.Out` capture-and-assert sites in `UtilitiesCS.Test` by adding a +`TextWriter` seam to the four production members they exercised, and removed the propagating +save/restore in `NLogTraceWriter_Test`. It deliberately did not touch the roughly 24 test classes +that replace `Console.Out` and never restore it, the two production `Console.WriteLine` +diagnostics in `OlTableExtensions.TableAccess.cs`, or the RS0030 analyzer severity. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 locally; GitHub Actions windows runner in CI +- Runtime: .NET Framework 4.8.1 test host, VSTest 18.9.0, MSTest 4.4.0, class-level parallelism +- Command/flags used: `vstest.console.exe /EnableCodeCoverage /InIsolation` +- Data source or fixture: not applicable; these are static-source observations + +## Steps to Reproduce + +Read the cited sites. None of these produces a failure today, because after #811 no test asserts +on `Console.Out` content. + +## Expected Behavior + +1. A test does not mutate process-wide state it never restores. +2. Production code reports diagnostics through the logger, not through the console. +3. Banned timing APIs are enforced by the build rather than by a reviewer's diff search. + +## Actual Behavior + +1. **Roughly 24 test classes install a `DebugTextWriter` with no restore.** They call + `Console.SetOut(new DebugTextWriter())` from a `[ClassInitialize]` or `[TestInitialize]` method + and never put the original writer back, so `Console.Out` is an arbitrary writer for the + remainder of the run. `ObsoleteBayesianClassifier_Tests.cs` does it twice. These are aggressors + rather than victims: none of them asserts on console content, so none can itself fail this way. + After #811 they harm nothing, because no test captures `Console.Out` any more. They remain the + reason `Console.Out` is not the console once the suite has started. #811 excluded them because + touching roughly 24 files across five test projects is a disproportionate blast radius for a + bugfix. + +2. **Two production `Console.WriteLine` diagnostics** at + `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.TableAccess.cs` lines 78 and 96, both + reading `Console.WriteLine($"Task timed out on try {counter}")` inside `GetTableInViewAsync`. + Production code in this repository is supposed to use the logger; these two write to whatever + writer the process currently holds, which after item 1 is a `DebugTextWriter`. #811 seamed + `EnumerateTable` in the same file but left these two untouched because they are not part of the + AC3 capture-and-assert population, and changing them is a logging change rather than a + determinism fix. + +3. **RS0030 is held at `suggestion` severity** in `.editorconfig`, and `BannedSymbols.txt` covers + only `DateTime.Now`, `DateTime.UtcNow`, `Random.Shared`, `Thread.Sleep` and `Task.Delay`. + `CancelAfter`, `TimeoutAfter`, `WaitOne` and `new CancellationTokenSource(int)` are not banned + at all. The consequence is that no toolchain step fails on a newly introduced sleep, so AC5 of + #811 had to be enforced by an explicit search over the diff + (`evidence/qa-gates/p7-t10-ac5-timing-hack-search.md`) rather than by the build. The severity is + held down because roughly 143 existing banned-symbol usages would otherwise break the build; see + issue #181. + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [ ] Medium +- [x] Low + +Low: no current failure depends on any of these. Item 1 is latent risk that returns the moment any +future test captures `Console.Out`; item 3 means the next timing hack will be caught only if a +reviewer looks for it. + +## Suspected Cause / Notes + +Items 1 and 2 are long-standing conventions that predate the determinism work. Item 3 is a +deliberate staged rollout recorded in `.claude/rules/csharp.md` under the severity-first ordering +invariant: new analyzer severities are set to `suggestion` before the analyzer is wired in, because +the type-check step runs `/p:TreatWarningsAsErrors=true` and would promote a `warning` to an error. + +## Proposed Fix / Validation Ideas + +- Replace the roughly 24 unrestored `Console.SetOut(new DebugTextWriter())` installs with either a + restoring scope or, better, removal: none of those classes asserts on console output, so the + redirect serves no test purpose. Doing this in one sweep across the five test projects is the + cheapest form. +- Route `OlTableExtensions.TableAccess.cs:78,96` through the existing `logger` and delete the + console writes. `Console.WriteLine` in that file then drops to 0. +- Clear the roughly 143 existing banned-symbol usages, then promote RS0030 from `suggestion` to + `warning`, and consider adding `CancelAfter`, `WaitOne` and `new CancellationTokenSource(int)` to + `BannedSymbols.txt` so that AC5-style constraints become build-enforced. Sequence matters: the + cleanup must land before the promotion or the nullable gate breaks. + +## Next Step + +- [ ] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch diff --git a/docs/features/potential/2026-09-08-etl-deadline-mechanics-follow-ups.md b/docs/features/potential/2026-09-08-etl-deadline-mechanics-follow-ups.md new file mode 100644 index 000000000..6e4ecc8c2 --- /dev/null +++ b/docs/features/potential/2026-09-08-etl-deadline-mechanics-follow-ups.md @@ -0,0 +1,122 @@ +# etl-deadline-mechanics-follow-ups + +- Date captured: 2026-09-08 +- Author: Dan Moisan +- Status: Draft + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +## Summary + +Residual ETL deadline mechanics left in place by issue #811, which placed the 250 ms per-row ETL +deadline and the 1000 ms dataframe-transform deadline under an injectable `TimeProvider` and added +a descriptive guard for the null-snapshot path. Each item below was deliberately excluded from +#811 to keep that fix's blast radius to a bugfix, and each is recorded here rather than left +implicit. None is a regression introduced by #811. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 locally; GitHub Actions windows runner in CI +- Runtime: .NET Framework 4.8.1 test host, VSTest 18.9.0, MSTest 4.4.0, class-level parallelism +- Command/flags used: `vstest.console.exe /EnableCodeCoverage /InIsolation` +- Data source or fixture: mocked `Table`, `MAPIFolder` and `Explorer` objects; no live Outlook + +## Steps to Reproduce + +Read the cited lines. These are design residuals rather than reproducible failures; item 4 below +is the only one with an observable symptom, and it requires a real Outlook store slow enough to +exceed the deadline. + +## Expected Behavior + +1. The per-row ETL budget is justified by measurement rather than by a constant chosen without one. +2. Every wall-clock deadline on the `GetEmailDataInViewAsync` path is under test control. +3. `EtlAsync` communicates a deadline expiry through its return type rather than through a null + forced into a non-nullable tuple element. +4. Dead code and stale documentation do not outlive the thing they describe. +5. `[DoNotParallelize]` is present only where a documented, verified reason requires it. + +## Actual Behavior + +1. **The 250 ms-per-row ETL budget** at `UtilitiesCS/OutlookObjects/Table/OlTableExtensions.Etl.cs` + (`int milliseconds = 250 * rowCount`) is small for real stores as well as for tests: a one-row + folder gets 250 ms for two thread-pool hops plus COM enumeration. #811 preserved the value + byte-for-byte and only made the clock injectable. Changing it is a timing change with no + deterministic test, which is why it was excluded. + +2. **The residual 2000 ms `GetTableInViewAsync` window** remains on the system clock. It is reached + from `DfDeedle.GetEmailDataInViewAsync` and is seamed by a + `Func` factory rather than by a `TimeProvider`, so threading it + would introduce a second seam type into the same call path. It has eight times the margin of + the 250 ms window and did not trip in the recorded failure, but it is the next-smallest + deadline on that path. Two options for the fix: pass a factory returning a never-cancelling + source from the test, or unify on `TimeProvider` through + `TimeProviderTaskExtensions.CreateCancellationTokenSource` (its availability in + `Microsoft.Bcl.TimeProvider 10.0.11` has not been verified). + +3. **`EtlAsync`'s null-through-suppression tuple contract.** On deadline expiry `EtlAsync` swallows + the `TimeoutException`, calls `tokenSource.Cancel()`, and returns `(data!, columnDictionary)` + with `data` null, forcing a null through a null-forgiving suppression into a non-nullable tuple + element. #811 added a guard at the consumer instead of changing the contract, because changing + it to `object[,]? data` with a rethrown `TimeoutException` widens the diff into every `EtlAsync` + consumer. The surviving contract is now documented by a test, + `OlTableExtensionsEtlClockTests.EtlAsync_DeadlineExpires_ReturnsNullDataAndCancelsTokenSource`, + so a future change to it will fail that test rather than pass silently. + +4. **The inert `(int, int)` `TimeoutAfter` overloads** at `UtilitiesCS/Threading/TimeOutTask.cs` + lines 824 and 924 wrap a call to the proxy-returning `(int, TimeProvider?)` overload in a + `catch (TimeoutException)` that can never execute, because the inner overload returns a proxy + that a timer later faults rather than throwing. `repeatAttempts` is never consulted and the + "attempts remaining" log line is unreachable. #811 removed the last production callers from + this path but did not delete the overloads: the file is 1011 lines, already over the 500-line + cap, and the overloads retain two test callers at + `UtilitiesCS.Test/Threading/TimeOutTask_Tests.cs:197,210` plus the dead `EtlAsyncOld`. This is + also recorded as Finding 1 of issue #798's `evidence/other/followup-promotions.md`. + +5. **A stale doc comment.** `UtilitiesCS/Extensions/DfDeedle.QfcColumns.cs` line 96 names + `TableEtlInvoker`, a static property #811 deleted and replaced with an optional `etl` parameter. + The comment now refers to a member that does not exist. #811 left the file untouched so that + its write set stayed at the 20 declared paths. + +6. **`[DoNotParallelize]` retained on `OlTableExtensions_Tests` without a soak.** #811 removed the + attribute from `StackGeek_Tests`, `PrettyPrint_Tests` and `DASLFilterParserTests` once the + `TextWriter` seam removed their console dependency, but retained it on + `UtilitiesCS.Test/OutlookObjects/Table/OlTableExtensions_Tests.cs` under spec Mitigation 4. The + class is 1846 lines of COM-mock tests, ten of which drive the 2000 ms `GetTableInViewAsync` + window, and it has never been soaked under class-level parallelism. Its comment was rewritten so + it no longer claims the console as the reason. Removing the attribute after a soak is the + follow-up. + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [ ] Medium +- [x] Low + +Low: none of these blocks a check today. Items 1 and 2 are latent robustness risks on slow stores; +items 3 to 6 are maintainability and clarity debt. + +## Suspected Cause / Notes + +Items 1 to 3 are consequences of a deadline design that predates the `TimeProvider` seam. Items 4 +and 5 are residue from that seam's incremental adoption across #798 and #811. Item 6 is a +deliberate conservative choice pending evidence. + +## Proposed Fix / Validation Ideas + +- Measure real ETL durations against folder size before changing the 250 ms constant, and record + the measurement in the issue. +- Choose one seam type for the `GetTableInViewAsync` window and apply it end to end. +- Change the `EtlAsync` tuple to `object[,]? data` and rethrow, updating every consumer in one + change; the existing contract test will need updating in the same commit. +- Delete the two inert `TimeoutAfter` overloads together with `EtlAsyncOld` and its test, which + also reduces `TimeOutTask.cs` toward the 500-line cap. +- Correct the `DfDeedle.QfcColumns.cs:96` doc comment. +- Soak `OlTableExtensions_Tests` without `[DoNotParallelize]` over at least ten full-suite runs + before removing the attribute. + +## Next Step + +- [ ] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch diff --git a/docs/features/potential/2026-09-08-ilglobals-loadopcodes-unsynchronised-static-race.md b/docs/features/potential/2026-09-08-ilglobals-loadopcodes-unsynchronised-static-race.md new file mode 100644 index 000000000..fac2d2822 --- /dev/null +++ b/docs/features/potential/2026-09-08-ilglobals-loadopcodes-unsynchronised-static-race.md @@ -0,0 +1,111 @@ +# ilglobals-loadopcodes-unsynchronised-static-race + +- Date captured: 2026-09-08 +- Author: Dan Moisan +- Status: Draft + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +## Summary + +An unsynchronised static-mutation race in `ILGlobals.LoadOpCodes()` is the sole reason acceptance +criterion AC4 of issue #811 is unmet. `LoadOpCodes()` reassigns a shared static opcode table to a +freshly allocated, all-default array and then fills it in a loop with no lock, no `Lazy`, no +`volatile`, and no static constructor. Two MSTest classes call it from class-level-parallel test +runs with no `[DoNotParallelize]` guard, so a reader on one worker can observe the table between +the reassignment and the fill. This entry records the defect so it can be tracked and fixed under +its own issue; no source change is made on the #811 branch. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 locally; GitHub Actions windows runner in CI +- Runtime: .NET Framework 4.8.1 test host, VSTest 18.9.0, MSTest 4.4.0, class-level parallelism +- Command/flags used: `vstest.console.exe /EnableCodeCoverage /InIsolation` +- Data source or fixture: no live Outlook or filesystem dependency; the race is between two MSTest + classes running under `[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)]` + +## Steps to Reproduce + +Read the cited lines below. The failure is intermittent and was observed once in ten consecutive +full-suite runs at 24 class-level workers; it does not reproduce deterministically on demand. + +## Expected Behavior + +1. `ILGlobals`'s opcode tables are either published once, immutably, before any reader can observe + them, or every read and write is synchronised. +2. Two MSTest classes that both call `LoadOpCodes()` do not run concurrently unless the shared + state they mutate is safe under concurrent access. +3. A test failure caused by a partially filled table is distinguishable, at the point it is first + investigated, from a genuine `MethodBodyReader` defect. + +## Actual Behavior + +1. **Mechanism.** `UtilitiesCS/NewtonsoftHelpers/SDIL Reader/ILGlobals.cs:123` reassigns + `singleByteOpCodes` to a freshly allocated all-default `OpCode[0x100]` and then fills it in a + reflection loop at line 137. Lines 117-118 declare both opcode tables as plain mutable + `public static` fields. There is no `lock`, no `Lazy`, no `volatile`, and no static + constructor guarding either table. + +2. **Reader sites.** `UtilitiesCS/NewtonsoftHelpers/SDIL Reader/MethodBodyReader.cs:105` and `:110` + read the tables with no synchronisation. + +3. **Participants.** `UtilitiesCS.Test/NewtonsoftHelpers/SDILReader/ILGlobals_Tests.cs` calls + `LoadOpCodes()` at lines 15, 26, 37, 47. `MethodBodyReader_Tests.cs` calls it at line 364 inside + the private `CreateReader` helper that every test in that class routes through. Neither class + carries `[DoNotParallelize]`, and the assembly runs at + `[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)]` + (`UtilitiesCS.Test/Properties/AssemblyInfo.cs:18-21`). `ClassLevel` scope serialises tests + within a class, so the second participant in any observed race must be the sibling class. + +4. **Observed signature (AC4 run 7).** `GetBodyCode_ReturnsConcatenatedInstructions` failed with + `Expected bodyCode ... to contain "ldstr"`. Only the entry at IL offset 0001 (`ldstr`, opcode + `0x72`) degraded; `nop`, `stloc.0`, `br.s`, `ldloc.0` and `ret` all resolved correctly. The two + spaces where the opcode name belongs are the empty `Name` of `default(OpCode)`, whose + `OperandType` is `InlineBrTarget` (0), which drives the numeric-operand branch and stores a raw + metadata token instead of calling `module.ResolveString(...)`. The printed value 1879067923 lies + in the `0x70……` String-metadata-token range, so the token was read correctly and merely not + resolved. That combination is explained only by a partially filled table read + mid-initialisation, not by a defect in token resolution itself. + +5. **Frequency.** Observed once in ten consecutive full-suite runs at 24 class-level workers. The + base rate is not established, and two clean baseline runs do not establish absence. + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [x] Medium +- [ ] Low + +Medium: while this is open, the required `mstest-coverage` check can still fail intermittently on +unrelated pull requests, which is the class of harm issue #811 exists to remove. The observed +frequency is low (once in ten runs) and no production code path is affected, but the failure is +non-deterministic and its recorded signature could be mistaken for a genuine `MethodBodyReader` +defect by a future investigator who does not have this entry. + +## Suspected Cause / Notes + +Both opcode tables are declared as ordinary mutable `public static` fields with no publication +guard. `LoadOpCodes()` is called repeatedly, once per test class instantiation path, rather than +once per process, so under `ClassLevel` parallelism two test classes can interleave: one thread's +reassignment-then-fill sequence in `ILGlobals.cs:123-137` is visible to a second thread's read in +`MethodBodyReader.cs:105`/`:110` at any point during the fill, including the instant right after +reassignment when the array is still all-default. + +## Proposed Fix / Validation Ideas + +Candidate fixes, in order of preference: + +1. Publish both tables once and immutably — a static constructor or a `Lazy` pair — and + make `LoadOpCodes()` a no-op or remove it. `LoadOpCodes()` is idempotent and the tables are + conceptually constant, so this removes the mutable window entirely. +2. Take a `lock` inside `LoadOpCodes()` and around the reads in `MethodBodyReader`. +3. Interim stopgap only: add `[DoNotParallelize]` to both `ILGlobals_Tests` and + `MethodBodyReader_Tests`. This suppresses rather than eliminates the race and should not be the + final state, by the same reasoning `spec.md` applies to the console stopgap under AC3 of + issue #811. + +## Next Step + +- [ ] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch diff --git a/docs/features/potential/promoted/2026-09-07-utilitiescs-test-determinism-780-803-594.md b/docs/features/potential/promoted/2026-09-07-utilitiescs-test-determinism-780-803-594.md new file mode 100644 index 000000000..e52910825 --- /dev/null +++ b/docs/features/potential/promoted/2026-09-07-utilitiescs-test-determinism-780-803-594.md @@ -0,0 +1,80 @@ +# utilitiescs-test-determinism-780-803-594 (Issue #811) + +- Date captured: 2026-09-07 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/utilitiescs-test-determinism-780-803-594/ (Issue #811) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #811 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/811 +- Last Updated: 2026-09-08 +## Summary + +Consolidates the `UtilitiesCS.Test` nondeterminism filed as #780, #803, and #594 into one item so the required `mstest-coverage` check stops failing on unrelated pull requests. #803 is a duplicate of the `DfDeedle_COM_Tests` `NullReferenceException` already in #594: `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` fails under parallel load because the class mutates static seams and production code at `DfDeedle.cs:186` dereferences `tableSnapshot.Item1` without a null check. #780 is `DictionaryExtensions.TryAddValuesAsync` cancelling its inner `Task.Run` after a hard-coded 500 ms wall-clock window, which the thread pool exceeds under coverage instrumentation with 24 class workers. #594 also carries two `Console.Out` races between concurrently executing tests. #592 (QuickFiler pump-host 60 s expiry) is deliberately not included: it is a different assembly and needs its own investigation. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 locally; GitHub Actions windows runner in CI +- Runtime: .NET Framework 4.8.1 test host, VSTest 18.9.0, MSTest 4.4.0, assembly-level parallelization with 24 class-scope workers +- Command/flags used: `vstest.console.exe /EnableCodeCoverage /InIsolation /Logger:trx "/TestCaseFilter:TestCategory!=LiveOutlook"`; the repository `mstest-coverage` job +- Data source or fixture: in-memory dictionaries; mocked `Table` and `MAPIFolder` in `DfDeedle_COM_Tests` + +## Steps to Reproduce + +1. Run the full suite under coverage with parallel class workers. Observe, intermittently: `TryAddValuesAsync_UpdatesExistingValue` failing with `TaskCanceledException` after ~20 s (passes in ~2 ms alone); `GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` failing with `NullReferenceException` at `DfDeedle.cs:186`; two `Console.Out` races. +2. Re-run the identical head commit with no change and observe all checks passing. Observed on PR #802 at `b2132349` (7107 of 7108 passed, then green on re-run) and during PR #779 verification on 2026-09-04. + +## Expected Behavior + +- Every test in `UtilitiesCS.Test` passes deterministically regardless of class ordering and parallel load. +- `TryAddValuesAsync` does not fail production callers on thread-pool scheduling latency. +- Production code at `DfDeedle.cs:186` does not dereference a possibly-null snapshot element. +- A full nine-assembly `/InIsolation` run reports zero failures on ten consecutive runs. + +## Actual Behavior + +Intermittent failures as above, each blocking a required check on an unrelated pull request and inviting re-run-until-green. + +## Logs / Screenshots + +- [x] Attached minimal logs or screenshot +- Snippet (#780, 2026-09-04): `System.Threading.Tasks.TaskCanceledException: A task was canceled. at UtilitiesCS.DictionaryExtensions.d__10\`2.MoveNext() in UtilitiesCS\Extensions\DictionaryExtensions.cs:line 179`. Local run 4767 tests, 4766 passed, the failing test alone took 21 s. +- Snippet (#803, PR #802 run 1): `System.NullReferenceException at UtilitiesCS.DfDeedle.GetEmailDataInViewAsync, UtilitiesCS/Extensions/DfDeedle.cs line 186`, Total 7108, Passed 7107, Failed 1. + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [x] Medium +- [ ] Low + +Medium: a nondeterministic failure in a required check blocks unrelated pull requests and erodes the signal the gate exists to provide. Production callers of `TryAddValuesAsync` may also receive a spurious cancellation under starvation. + +## Suspected Cause / Notes + +- #780: wall-clock timeout in production code measures scheduling latency, not the operation; the fix is to remove the hard-coded window or inject a `TimeProvider` so tests use `FakeTimeProvider`, per `.claude/rules/csharp.md` time-seam guidance. +- #803 / #594 item 1: static seam mutation in `DfDeedle_COM_Tests` races other classes; the production null dereference at `DfDeedle.cs:186` turns a race into an NRE. Fix both: make the seam instance-scoped or serialize the class with `[DoNotParallelize]` on a documented basis, and guard the snapshot dereference. +- #594 items 2 and 3: eliminate the shared-console dependency rather than serializing with sleeps or retries. +- Superseded issues: #780, #803, #594 (close with a pointer to this issue). + +## Proposed Fix / Validation Ideas + +Acceptance criteria: + +- [ ] AC1: `TryAddValuesAsync` no longer cancels on a fixed wall-clock window, or the window is driven by an injected `TimeProvider`; the test passes deterministically under 24-worker parallel coverage runs. +- [ ] AC2: `DfDeedle_COM_Tests` no longer mutates process-wide static seams in a way another class can observe, and `DfDeedle.cs:186` guards the null snapshot element with a descriptive failure. +- [ ] AC3: The two `Console.Out` races are removed by eliminating the shared-console dependency. +- [ ] AC4: A full nine-assembly `/InIsolation` run with `TestCategory!=LiveOutlook` reports zero failures on ten consecutive runs, recorded as evidence. +- [ ] AC5: No test is stabilized by a sleep, a retry, or a timing tolerance. + +Validation: + +- [ ] Unit coverage areas: `DictionaryExtensions.TryAddValuesAsync` with `FakeTimeProvider`; `DfDeedle.GetEmailDataInViewAsync` null-snapshot path. +- [ ] Integration scenario to retest: ten consecutive full-suite runs locally and one CI run. +- [ ] Manual verification notes: none. + +## Next Step + +- [ ] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch