diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs new file mode 100644 index 000000000..4a39b3146 --- /dev/null +++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs @@ -0,0 +1,463 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using QuickFiler.Controllers; +using QuickFiler.Viewers; +using UtilitiesCS; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Test.Controllers +{ + /// + /// Headless issue #799 regression tests for the breadcrumb router's score join (AC6), the + /// archive-relative filing invariant under a trimmed chain (AC3), and the zero-candidate row + /// suppression (AC7). Every collaborator is a Moq seam or a real pure type; this file creates + /// no WebView2 control, no Outlook COM object, and no message pump. + /// + /// Authored for C# 7.3. QuickFiler.Test declares no LangVersion element and targets v4.8.1, so + /// it compiles at the 7.3 default while every other project in scope is at Latest, preview, or + /// 12.0. Do not introduce target-typed new, is not null, switch expressions, or + /// nullable reference annotations here: they surface as CS8370 at build time, not at edit time. + /// + /// + [TestClass] + public sealed class BreadcrumbBridgeRouterScoreJoinTests + { + private const string ArchiveRoot = @"\Archive"; + private const string RelativeTarget = @"Clients\North"; + private const string RootedTarget = @"\Archive\Clients\North"; + private const string ClientsPath = @"\Archive\Clients"; + + /// + /// AC6: the Efc surface hands the router the RAW score paths, which are archive-rooted, + /// while the presented rows are archive-relative stems. The join must still find the score, + /// or an archive-rooted suggestion silently loses its percentage. + /// + [TestMethod] + public void BindRowsAsync_RootedScoreAndRelativeRow_RendersThePercentage() + { + // Arrange + var documents = new List(); + var provider = new Mock(MockBehavior.Strict); + var host = StrictHost(documents); + FolderTreeNodeKey key = Key(RootedTarget); + SetupChain( + provider, + RootedTarget, + key, + TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North") + ); + var router = RouterOver(provider, host); + + // Act: presented row is the stem; the score carries the rooted path. + router + .BindRowsAsync( + new[] { RelativeTarget }, + new[] { new FolderScore(RootedTarget, 730, 0.73) }, + ArchiveRoot, + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + + // Assert + documents.Should().ContainSingle(); + documents[0].Should().Contain("73%", "an archive-rooted score must still join"); + } + + /// + /// Decision D7: the projected score key is ADDED alongside the raw key, never substituted + /// for it. A substitution would fix the stem-presented case and silently break this one. + /// + [TestMethod] + public void BindRowsAsync_RootedScoreAndRootedRow_StillRendersThePercentage() + { + // Arrange + var documents = new List(); + var provider = new Mock(MockBehavior.Strict); + var host = StrictHost(documents); + FolderTreeNodeKey key = Key(RootedTarget); + SetupChain( + provider, + RootedTarget, + key, + TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North") + ); + var router = RouterOver(provider, host); + + // Act: presented row and score both carry the rooted path. + router + .BindRowsAsync( + new[] { RootedTarget }, + new[] { new FolderScore(RootedTarget, 730, 0.73) }, + ArchiveRoot, + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + + // Assert + documents.Should().ContainSingle(); + documents[0].Should().Contain("73%", "the rooted-presented case must not regress"); + } + + /// + /// The public three-argument overload forwards an empty archive root, so the projection is + /// the identity and no existing caller changes behaviour. + /// + [TestMethod] + public void BindRowsAsync_EmptyBoundRoot_LeavesTheJoinUnchanged() + { + // Arrange + var documents = new List(); + var provider = new Mock(MockBehavior.Strict); + var host = StrictHost(documents); + FolderTreeNodeKey key = Key(RelativeTarget); + SetupChain( + provider, + RelativeTarget, + key, + TwoSegmentChain(@"Clients", "Clients", RelativeTarget, "North") + ); + var router = RouterOver(provider, host); + + // Act: the public overload, which supplies no archive root at all. + router + .BindRowsAsync( + new[] { RelativeTarget }, + new[] { new FolderScore(RelativeTarget, 730, 0.73) }, + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + + // Assert + documents.Should().ContainSingle(); + documents[0].Should().Contain("73%"); + } + + /// + /// AC3, pinned as its own criterion rather than as an incidental consequence of the trim: + /// with an ancestor chain that begins BELOW the archive root, the filing target and the + /// joined score key are both still the archive-relative stem. + /// + [TestMethod] + public void BindRowsAsync_TrimmedChain_PreservesFilingTargetAndScoreKey() + { + // Arrange: the chain carries no store segment and no Archive segment. + var documents = new List(); + var provider = new Mock(MockBehavior.Strict); + var host = StrictHost(documents); + FolderTreeNodeKey key = Key(RootedTarget); + SetupChain( + provider, + RootedTarget, + key, + TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North") + ); + var router = RouterOver(provider, host); + + // Act + router + .BindRowsAsync( + new[] { RelativeTarget }, + new[] { new FolderScore(RootedTarget, 730, 0.73) }, + ArchiveRoot, + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + router + .ProcessInboundAsync("{\"type\":\"rowSelected\",\"rowId\":\"row-0\"}") + .GetAwaiter() + .GetResult(); + + // Assert + router + .SelectedFolderPath.Should() + .Be(RelativeTarget, "the filing target stays archive-relative (#439)"); + documents[documents.Count - 1] + .Should() + .Contain("73%", "the score key stays joined to the archive-relative stem"); + } + + /// + /// The spec's integration scenario, driven entirely through the router: a banner row, a + /// suggestion row, a search-result row, the trash pseudo-row, and one stale label that the + /// provider cannot resolve. Lineage renders on both folder row kinds only. + /// + [TestMethod] + public void BindRowsAsync_MixedRowSet_RendersLineageOnFolderRowsOnly() + { + // Arrange + const string searchTarget = @"Search\Follow Up"; + const string searchRooted = @"\Archive\Search\Follow Up"; + const string staleTarget = @"Clients\Stale"; + var documents = new List(); + var provider = new Mock(MockBehavior.Strict); + var host = StrictHost(documents); + FolderTreeNodeKey suggestionKey = Key(RootedTarget); + FolderTreeNodeKey searchKey = Key(searchRooted); + SetupChain( + provider, + RootedTarget, + suggestionKey, + TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North") + ); + SetupChain( + provider, + searchRooted, + searchKey, + TwoSegmentChain(@"\Archive\Search", "Search", searchRooted, "Follow Up") + ); + provider + .Setup(p => + p.ResolveLeafKeyAsync(@"\Archive\Clients\Stale", It.IsAny()) + ) + .ReturnsAsync((FolderTreeNodeKey)null); + var router = RouterOver(provider, host); + + // Act + router + .BindRowsAsync( + new[] + { + "==== SUGGESTIONS ====", + RelativeTarget, + searchTarget, + "Trash to Delete", + staleTarget, + }, + new[] { new FolderScore(RootedTarget, 730, 0.73) }, + ArchiveRoot, + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + + // Assert: exactly two rows render an ancestor separator, and they are the two folder + // row kinds; the banner, the trash pseudo-row and the stale label render none. + string document = documents[0]; + Occurrences(document, "class=\"sep\"") + .Should() + .Be(2, "lineage renders on the suggestion and search rows only"); + document.Should().Contain("title=\"" + ClientsPath + "\""); + document.Should().Contain("title=\"\\Archive\\Search\""); + document.Should().Contain("row banner"); + document.Should().Contain("row selectable trash"); + document.Should().Contain(">Stale<", "the stale label keeps the leaf-only fallback"); + } + + /// + /// AC7 row half, and the only test here that takes the TRUE arm of the suppression + /// predicate. The suppressed row sits in the MIDDLE of the presented sequence, so a + /// suppression that removed the row from the built list without removing it from the + /// presented list would misalign every later row's segment keys. + /// + [TestMethod] + public void BindRowsAsync_ZeroCandidateLabel_SuppressesTheRowAndKeepsSegmentKeysAligned() + { + // Arrange + const string vendorsTarget = @"Vendors\South"; + const string vendorsRooted = @"\Archive\Vendors\South"; + const string vendorsPath = @"\Archive\Vendors"; + const string staleTarget = @"Clients\Stale"; + const string staleRooted = @"\Archive\Clients\Stale"; + var documents = new List(); + var provider = new Mock(MockBehavior.Strict); + + // Moq throws when an interface is added to a mock whose object already exists, so the + // As<> call precedes every use of provider.Object. + var absence = provider.As(); + absence.Setup(a => a.IsAbsentLabel(It.IsAny())).Returns(false); + absence.Setup(a => a.IsAbsentLabel(staleRooted)).Returns(true); + absence.Setup(a => a.IsAbsentLabel(staleTarget)).Returns(true); + + var host = StrictHost(documents); + FolderTreeNodeKey northKey = Key(RootedTarget); + FolderTreeNodeKey southKey = Key(vendorsRooted); + FolderTreeNodeKey vendorsKey = Key(vendorsPath); + SetupChain( + provider, + RootedTarget, + northKey, + TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North") + ); + SetupChain( + provider, + vendorsRooted, + southKey, + TwoSegmentChain(vendorsPath, "Vendors", vendorsRooted, "South") + ); + provider + .Setup(p => p.ResolveLeafKeyAsync(staleRooted, It.IsAny())) + .ReturnsAsync((FolderTreeNodeKey)null); + provider + .Setup(p => + p.GetImmediateSubfoldersAsync(vendorsKey, It.IsAny()) + ) + .ReturnsAsync(new[] { Segment(vendorsRooted, "South", false) }); + var router = RouterOver(provider, host); + + // Act + router + .BindRowsAsync( + new[] { "==== SUGGESTIONS ====", RelativeTarget, staleTarget, vendorsTarget }, + new[] { new FolderScore(RootedTarget, 730, 0.73) }, + ArchiveRoot, + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + router + .ProcessInboundAsync( + "{\"type\":\"segmentActivate\",\"rowId\":\"row-2\",\"segmentIndex\":0}" + ) + .GetAwaiter() + .GetResult(); + router + .ProcessInboundAsync("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-2\"}") + .GetAwaiter() + .GetResult(); + + // Assert + string document = documents[0]; + document.Should().NotContain("Stale", "the zero-candidate label is suppressed"); + Occurrences(document, "data-row-id=\"row-") + .Should() + .Be(3, "one presented row of four was suppressed"); + provider.Verify( + p => p.GetImmediateSubfoldersAsync(vendorsKey, It.IsAny()), + Times.Once + ); + } + + /// + /// Decision D-B restricts suppression to the zero-candidate cause. An ambiguous label also + /// yields a null chain, but it is not absent and must still render with today's fallback. + /// + [TestMethod] + public void BindRowsAsync_AmbiguousLabel_IsNotSuppressed() + { + // Arrange + const string ambiguousTarget = @"Clients\Stale"; + const string ambiguousRooted = @"\Archive\Clients\Stale"; + var documents = new List(); + var provider = new Mock(MockBehavior.Strict); + var absence = provider.As(); + absence.Setup(a => a.IsAbsentLabel(It.IsAny())).Returns(false); + + var host = StrictHost(documents); + FolderTreeNodeKey northKey = Key(RootedTarget); + SetupChain( + provider, + RootedTarget, + northKey, + TwoSegmentChain(ClientsPath, "Clients", RootedTarget, "North") + ); + provider + .Setup(p => p.ResolveLeafKeyAsync(ambiguousRooted, It.IsAny())) + .ReturnsAsync((FolderTreeNodeKey)null); + var router = RouterOver(provider, host); + + // Act + router + .BindRowsAsync( + new[] { RelativeTarget, ambiguousTarget }, + new[] { new FolderScore(RootedTarget, 730, 0.73) }, + ArchiveRoot, + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + + // Assert + string document = documents[0]; + document.Should().Contain(">Stale<", "ambiguity is not absence"); + Occurrences(document, "data-row-id=\"row-").Should().Be(2); + } + + private static BreadcrumbBridgeRouter RouterOver( + Mock provider, + Mock host + ) + { + return new BreadcrumbBridgeRouter( + provider.Object, + host.Object, + new BreadcrumbMessageCodec(), + new BreadcrumbHtmlRenderer(), + new BreadcrumbOutboundQueue(host.Object) + ); + } + + private static Mock StrictHost(List documents) + { + var host = new Mock(MockBehavior.Strict); + host.SetupGet(h => h.IsCoreInitialized).Returns(true); + host.Setup(h => h.NavigateToString(It.IsAny())) + .Callback(html => documents.Add(html)); + host.Setup(h => h.PostMessageJson(It.IsAny())); + return host; + } + + private static void SetupChain( + Mock provider, + string hierarchyPath, + FolderTreeNodeKey key, + IReadOnlyList chain + ) + { + provider + .Setup(p => p.ResolveLeafKeyAsync(hierarchyPath, It.IsAny())) + .ReturnsAsync(key); + provider + .Setup(p => p.GetAncestorChainAsync(key, It.IsAny())) + .ReturnsAsync(chain); + } + + /// + /// An ancestor chain that already begins below the archive root, which is the shape the + /// provider returns once the #799 trim is in place. + /// + private static IReadOnlyList TwoSegmentChain( + string parentPath, + string parentName, + string leafPath, + string leafName + ) + { + return new[] + { + Segment(parentPath, parentName, true), + Segment(leafPath, leafName, false), + }; + } + + private static FolderBreadcrumbSegment Segment(string path, string name, bool hasChildren) + { + return new FolderBreadcrumbSegment(Key(path), name, path, hasChildren); + } + + private static FolderTreeNodeKey Key(string path) + { + return new FolderTreeNodeKey("archive-store", path, path); + } + + private static int Occurrences(string haystack, string needle) + { + int count = 0; + int index = haystack.IndexOf(needle, StringComparison.Ordinal); + while (index >= 0) + { + count++; + index = haystack.IndexOf(needle, index + needle.Length, StringComparison.Ordinal); + } + + return count; + } + } +} diff --git a/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs b/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs index d3c80da5f..87189bdf3 100644 --- a/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs +++ b/QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs @@ -208,6 +208,12 @@ public void AssignFolderComboBox_WhenArchiveRootedPredeterminedFolder_Preselects /// path that does not start with the root, a path equal to the root plus a separator with /// nothing after it, and a case-differing root are each pinned, so the helper cannot be /// simplified into something that mangles a non-archive path. + /// + /// Retargeted by issue #799 AC4: an EMPTY archive root is now the identity projection. + /// The former behaviour formed an archive prefix of a single separator and stripped it, + /// which produced a value that was neither a valid full path nor a valid archive-relative + /// stem. The other five boundary cases are unchanged, because the shared projection + /// reproduces each of them exactly. /// [TestMethod] public void ProjectPredeterminedFolder_BoundaryCases_MatchFolderPredictorProjection() @@ -220,9 +226,9 @@ public void ProjectPredeterminedFolder_BoundaryCases_MatchFolderPredictorProject .ProjectPredeterminedFolder(@"\\Archive\Projects\Active", string.Empty) .Should() .Be( - @"\Archive\Projects\Active", - "a non-null globals with an EMPTY archive root gives FolderPredictor an " - + "archivePrefix of one separator, which it strips" + @"\\Archive\Projects\Active", + "AC4 of issue #799 removed the empty-root strip, so an empty archive root " + + "is now the identity projection" ); QfcItemController .ProjectPredeterminedFolder(null, @"\\Archive") @@ -243,12 +249,12 @@ public void ProjectPredeterminedFolder_BoundaryCases_MatchFolderPredictorProject } /// - /// Issue #678, remediation R2. The boundary case the projection previously got wrong: a - /// non-null globals whose ArchiveRootPath is EMPTY, with a leading-separator - /// suggestion path. FolderPredictor.ProjectSuggestionPath guards only on - /// _globals is null and then forms ArchiveRootPath + "\\" unconditionally, so - /// in this state its prefix is a single separator and its FolderArray entries ARE - /// stripped. The carried PredeterminedFolder must be projected the same way, or + /// Issue #678, remediation R2, re-derived against the issue #799 AC4 behaviour: a non-null + /// globals whose ArchiveRootPath is EMPTY, with a leading-separator suggestion path. + /// The shared projection now leaves BOTH the FolderArray entries and the carried + /// PredeterminedFolder unchanged in that state, so the two must agree on the + /// unstripped value. The invariant under test is unchanged and is the one that matters: + /// the carried value must be projected exactly as the array entries are, or /// FolderContains misses and the selection falls back to the index-1 entry — the /// exact AC12 defect the change set out to close. /// @@ -261,7 +267,10 @@ public void AssignFolderComboBox_WhenEmptyArchiveRootAndLeadingSeparator_Presele { // Arrange const string RawSuggestion = @"\Projects\Active"; - const string ProjectedSuggestion = @"Projects\Active"; + + // #799 AC4: an empty archive root is the identity projection, so the projected value + // and the raw value are the same string. + const string ProjectedSuggestion = RawSuggestion; var mock = new Mock(); mock.SetupGet(v => v.InvokeRequired).Returns(false); @@ -288,8 +297,8 @@ public void AssignFolderComboBox_WhenEmptyArchiveRootAndLeadingSeparator_Presele mock.Verify( v => v.SetFolderSelectedItem(ProjectedSuggestion), Times.Once(), - "an empty archive root still strips the leading separator in FolderPredictor, so " - + "the carried value must be stripped the same way to match" + "an empty archive root is the identity projection in FolderPredictor, so the " + + "carried value must be carried through the same way to match" ); mock.Verify( v => v.SetFolderSelectedIndex(It.IsAny()), diff --git a/QuickFiler.Test/QuickFiler.Test.csproj b/QuickFiler.Test/QuickFiler.Test.csproj index 8a3a30030..f0d479502 100644 --- a/QuickFiler.Test/QuickFiler.Test.csproj +++ b/QuickFiler.Test/QuickFiler.Test.csproj @@ -63,6 +63,7 @@ + diff --git a/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs b/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs index 0232cdf90..e8d1dedd9 100644 --- a/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs +++ b/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs @@ -23,6 +23,12 @@ public sealed partial class BreadcrumbBridgeRouter ); private readonly IFolderHierarchyProvider _provider; + + // #799 AC7: obtained by an `as` cast in the constructor, so no constructor signature + // changes and no existing test breaks. A Mock<IFolderHierarchyProvider> is not an + // IFolderLabelAbsenceReport, so this stays null and suppression is inert in every existing + // router test. + private readonly IFolderLabelAbsenceReport? _absenceReport; private readonly IBreadcrumbWebHost _host; private readonly BreadcrumbMessageCodec _codec; private readonly BreadcrumbHtmlRenderer _renderer; @@ -47,6 +53,7 @@ BreadcrumbOutboundQueue outboundQueue ) { _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _absenceReport = provider as IFolderLabelAbsenceReport; _host = host ?? throw new ArgumentNullException(nameof(host)); _codec = codec ?? throw new ArgumentNullException(nameof(codec)); _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); @@ -104,6 +111,7 @@ CancellationToken cancellationToken var chains = new Dictionary>( StringComparer.OrdinalIgnoreCase ); + HashSet? suppressed = null; _boundRoot = string.IsNullOrWhiteSpace(archiveRootPath) ? string.Empty : archiveRootPath.TrimEnd('\\', '/'); @@ -126,15 +134,34 @@ CancellationToken cancellationToken if (chain != null) { chains[text] = chain; + continue; + } + + // #799 AC7 (Efc surface only, per decision D5). A null chain arising from + // cancellation or from a provider fault is NOT suppressed: those rows are not + // known-absent, and only the zero-candidate classification is. + if ( + hierarchyPath != null + && _absenceReport != null + && _absenceReport.IsAbsentLabel(hierarchyPath) + ) + { + suppressed ??= new HashSet(StringComparer.OrdinalIgnoreCase); + suppressed.Add(text); } } + IReadOnlyList retainedRows = RetainedRows(presentedRows, suppressed); _rows = _builder.BuildRows( - presentedRows, + retainedRows, text => chains.TryGetValue(text, out var chain) ? chain : null, - scores + WithProjectedScoreKeys(scores) ); - AttachSegmentKeys(presentedRows, chains); + + // The SAME retained list is handed to both calls: AttachSegmentKeys indexes the + // presented rows by row index, so an unfiltered list here would mis-align every row + // after the suppressed one. + AttachSegmentKeys(retainedRows, chains); _selectedRowId = null; // #499: the rows just rebuilt are a new set, so a folder path selected against the @@ -149,6 +176,82 @@ CancellationToken cancellationToken DeliverDocument(); } + /// + /// AC6: emits every original score UNCHANGED and, additionally, one archive-relative alias + /// for each score whose path is archive-rooted. The addition is what makes it safe — a + /// substitution would fix the stem-presented case and silently break the rooted-presented + /// case — and the row builder's probability index assigns through its indexer, so a + /// duplicate key is tolerated rather than throwing. + /// + private IEnumerable WithProjectedScoreKeys(IEnumerable scores) + { + // An empty bound root makes the projection the identity, so the public three-argument + // overload's callers see no change and allocate nothing. A null sequence is passed + // through, null-forgiving, so the row builder keeps raising its own + // ArgumentNullException rather than this method raising a different one. + if (scores == null || _boundRoot.Length == 0) + { + return scores!; + } + + var joined = new List(); + foreach (FolderScore score in scores) + { + joined.Add(score); + if (score.FolderPath == null) + { + continue; + } + + // Null-forgiving: ToDisplayStem returns null only for a null folderPath, which the + // guard above excludes; unsuppressed the construction below is CS8604. + string projected = ArchiveStemProjection.ToDisplayStem( + score.FolderPath, + _boundRoot + )!; + if (!string.Equals(projected, score.FolderPath, StringComparison.Ordinal)) + { + joined.Add(new FolderScore(projected, score.Score, score.Probability)); + } + } + + return joined; + } + + /// + /// AC7: the presented sequence with the known-absent labels removed, filtered BEFORE row + /// construction because row ids are assigned as row-<index> over this sequence. + /// Returns the original instance when nothing was suppressed. + /// + private IReadOnlyList RetainedRows( + IReadOnlyList presentedRows, + HashSet? suppressed + ) + { + if (suppressed == null || suppressed.Count == 0) + { + return presentedRows; + } + + var retained = new List(presentedRows.Count); + foreach (string text in presentedRows) + { + if (!string.IsNullOrEmpty(text) && suppressed.Contains(text)) + { + continue; + } + + // Null-forgiving: a null entry is carried through exactly as the unfiltered list + // carried it, so the row builder's handling of it is unchanged. + retained.Add(text!); + } + + log.Debug( + $"#799 AC7: suppressed {suppressed.Count} zero-candidate breadcrumb row(s) of {presentedRows.Count} presented." + ); + return retained; + } + private string? ToHierarchyPath(string presentedTarget) { // #609 preserved: a RELATIVE presented target stays root-prefixed for the lookup. diff --git a/QuickFiler/Controllers/EfcFormController.cs b/QuickFiler/Controllers/EfcFormController.cs index d8610ed44..9019b939d 100644 --- a/QuickFiler/Controllers/EfcFormController.cs +++ b/QuickFiler/Controllers/EfcFormController.cs @@ -1051,7 +1051,8 @@ private void ConfigureBreadcrumbControl() new WebView2CoreInitializer() ); var provider = new UtilitiesCS.OutlookObjects.Folder.OutlookFolderHierarchyProvider( - _globals.Ol.FolderTreeService + _globals.Ol.FolderTreeService, + () => _globals.Ol.ArchiveRootPath ); _router = new BreadcrumbBridgeRouter( provider, diff --git a/QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs b/QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs new file mode 100644 index 000000000..cccffb320 --- /dev/null +++ b/QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs @@ -0,0 +1,41 @@ +using QuickFiler.Viewers; + +namespace QuickFiler.Controllers +{ + internal partial class QfcItemController + { + // #351: idempotently creates the host-neutral breadcrumb pipeline on the concrete viewer + // so folder population/selection are correct even before WebView2 core init completes. + // The 9101 provider is DI-resolved from the injected globals' folder-tree service seam — + // no live Outlook query is issued inside breadcrumb code (G6). Skipped for mock viewers + // (unit tests drive the coordinator directly through its own seams). + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal void EnsureBreadcrumbPipeline() + { + if (!(_itemViewer is ItemViewer viewer)) + { + return; + } + + if (viewer.BreadcrumbCoordinator == null) + { + var provider = new UtilitiesCS.OutlookObjects.Folder.OutlookFolderHierarchyProvider( + _globals.Ol.FolderTreeService, + () => _globals.Ol.ArchiveRootPath + ); + viewer.InitializeBreadcrumbPipeline(provider); + } + + if (!ReferenceEquals(_breadcrumbViewer, viewer)) + { + if (_breadcrumbViewer != null) + { + _breadcrumbViewer.BreadcrumbUnhandledArrow -= OnBreadcrumbUnhandledArrow; + } + _breadcrumbViewer = viewer; + _breadcrumbViewer.BreadcrumbUnhandledArrow -= OnBreadcrumbUnhandledArrow; + _breadcrumbViewer.BreadcrumbUnhandledArrow += OnBreadcrumbUnhandledArrow; + } + } + } +} diff --git a/QuickFiler/Controllers/QfcItemController.FolderHandling.cs b/QuickFiler/Controllers/QfcItemController.FolderHandling.cs index ffb3b1b2c..d11e691de 100644 --- a/QuickFiler/Controllers/QfcItemController.FolderHandling.cs +++ b/QuickFiler/Controllers/QfcItemController.FolderHandling.cs @@ -224,10 +224,10 @@ public void AssignFolderComboBox() // FolderPredictor.ProjectSuggestionPath, while the carried PredeterminedFolder is // the RAW suggestion path the scorer read from Suggestions. Without projecting the // carried value the same way, FolderContains misses every archive-rooted - // suggestion and the selection silently falls back to the index-1 entry. The - // projection is duplicated here rather than reused because - // FolderPredictor.ProjectSuggestionPath is private and lives under UtilitiesCS, - // which this change may not modify. + // suggestion and the selection silently falls back to the index-1 entry. #799 AC4: + // both sides now route through the one shared projection + // ArchiveStemProjection.ToDisplayStem, so they agree by construction rather than by + // duplication. string predetermined = ProjectPredeterminedFolder( _predeterminedFolder, _globals is null ? null : (_globals.Ol?.ArchiveRootPath ?? string.Empty) @@ -250,38 +250,21 @@ public void AssignFolderComboBox() } /// - /// #678 AC12. Projects a raw suggestion path onto the form FolderPredictor.FolderArray - /// stores, so a containment probe against the combo box can match: strip - /// plus a trailing separator from the front of - /// , case-insensitively, but only when the remainder is - /// non-empty. #678 R2: the projection mirrors FolderPredictor.ProjectSuggestionPath - /// for every non-null and non-null - /// . A NULL stands for - /// that member's _globals is null guard and yields the identity; an EMPTY one does - /// not, because that member forms its prefix unconditionally and so strips a single leading - /// separator in that state. - /// - /// Two divergences from that member remain and are deliberate, and both are null-safety - /// differences rather than projection differences. First, a null or empty - /// is returned unchanged rather than dereferenced; - /// ProjectSuggestionPath does not guard it because its input comes from - /// Suggestions. Second, a non-null globals with a null Ol is treated by the - /// call site as an empty archive root rather than reproducing that member's null - /// dereference. + /// #678 AC12, re-derived under #799 AC4. Projects a raw suggestion path onto the form + /// FolderPredictor.FolderArray stores, so a containment probe against the combo box + /// can match. This member and FolderPredictor.ProjectSuggestionPath now share ONE + /// projection, ArchiveStemProjection.ToDisplayStem, so they agree by construction + /// rather than by duplication, and the empty-root one-separator strip that used to make + /// them diverge was eliminated by AC4: a null, empty, or whitespace-only + /// is now the identity projection, as is a null or empty + /// and any path that is not strictly under the root. /// internal static string ProjectPredeterminedFolder(string folderPath, string archiveRootPath) { - if (string.IsNullOrEmpty(folderPath) || archiveRootPath is null) - { - return folderPath; - } - - string archivePrefix = archiveRootPath + "\\"; - return - folderPath.StartsWith(archivePrefix, StringComparison.OrdinalIgnoreCase) - && folderPath.Length > archivePrefix.Length - ? folderPath.Substring(archivePrefix.Length) - : folderPath; + return UtilitiesCS.OutlookObjects.Folder.ArchiveStemProjection.ToDisplayStem( + folderPath, + archiveRootPath + ); } /// diff --git a/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs b/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs index 7fefde65f..f10797758 100644 --- a/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs +++ b/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs @@ -129,39 +129,6 @@ await ConfigureAndAttachBreadcrumbAsync( //}, Token, TaskContinuationOptions.OnlyOnRanToCompletion, ui); } - // #351: idempotently creates the host-neutral breadcrumb pipeline on the concrete viewer - // so folder population/selection are correct even before WebView2 core init completes. - // The 9101 provider is DI-resolved from the injected globals' folder-tree service seam — - // no live Outlook query is issued inside breadcrumb code (G6). Skipped for mock viewers - // (unit tests drive the coordinator directly through its own seams). - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - internal void EnsureBreadcrumbPipeline() - { - if (!(_itemViewer is ItemViewer viewer)) - { - return; - } - - if (viewer.BreadcrumbCoordinator == null) - { - var provider = new UtilitiesCS.OutlookObjects.Folder.OutlookFolderHierarchyProvider( - _globals.Ol.FolderTreeService - ); - viewer.InitializeBreadcrumbPipeline(provider); - } - - if (!ReferenceEquals(_breadcrumbViewer, viewer)) - { - if (_breadcrumbViewer != null) - { - _breadcrumbViewer.BreadcrumbUnhandledArrow -= OnBreadcrumbUnhandledArrow; - } - _breadcrumbViewer = viewer; - _breadcrumbViewer.BreadcrumbUnhandledArrow -= OnBreadcrumbUnhandledArrow; - _breadcrumbViewer.BreadcrumbUnhandledArrow += OnBreadcrumbUnhandledArrow; - } - } - /// Configures the lazy popup with the existing environment and active theme. internal void ConfigureBreadcrumbDropDown( ItemViewer viewer, diff --git a/QuickFiler/QuickFiler.csproj b/QuickFiler/QuickFiler.csproj index 47a5c27cc..35def2f76 100644 --- a/QuickFiler/QuickFiler.csproj +++ b/QuickFiler/QuickFiler.csproj @@ -333,6 +333,7 @@ + diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs new file mode 100644 index 000000000..e89a70ef7 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs @@ -0,0 +1,217 @@ +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Unit tests for and its single member + /// TryTrimBelowArchiveRoot, the ancestor-chain trim introduced by issue #799 (AC1, AC2). + /// Every chain is built from literals through the + /// four-argument constructor, so there is no snapshot, no provider, no COM, and no mock: the + /// unit under test is the trim itself and nothing else. + /// + [TestClass] + public sealed class ArchiveChainProjectionTests + { + private const string StorePath = "\\\\Mailbox - User"; + private const string ArchiveRoot = "\\\\Mailbox - User\\Archive"; + private const string ClientsPath = "\\\\Mailbox - User\\Archive\\Clients"; + private const string AcmePath = "\\\\Mailbox - User\\Archive\\Clients\\Acme"; + + /// + /// The ordinary case: the chain passes through the archive root, so the trim yields exactly + /// the segments after that node. Segment identity is preserved by reference, which proves + /// the trim is a projection over the existing chain rather than a rebuild that could drop a + /// key or a display name. + /// + [TestMethod] + public void TryTrimBelowArchiveRoot_ChainPassesThroughRoot_ReturnsSegmentsAfterTheRoot() + { + // Arrange + var chain = FullChain(); + + // Act + bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot( + chain, + ArchiveRoot, + out var trimmed + ); + + // Assert + trimmedOk.Should().BeTrue(); + trimmed.Should().HaveCount(2); + trimmed[0].Should().BeSameAs(chain[2], "segment identity is preserved by reference"); + trimmed[1].Should().BeSameAs(chain[3]); + } + + /// + /// A chain that never reaches the archive root is the AC2 diagnostic case: the trim reports + /// failure and yields nothing, so the caller can log once and fall back. + /// + [TestMethod] + public void TryTrimBelowArchiveRoot_ChainMissesTheRoot_ReturnsFalseAndEmptyOutput() + { + // Arrange + var chain = new List + { + Segment("inbox", StorePath + "\\Inbox", "Inbox"), + Segment("inbox-clients", StorePath + "\\Inbox\\Clients", "Clients"), + }; + + // Act + bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot( + chain, + ArchiveRoot, + out var trimmed + ); + + // Assert + trimmedOk.Should().BeFalse(); + trimmed.Should().BeEmpty(); + } + + /// + /// When the LEAF is the archive root there is nothing below it to render, so the trim + /// reports failure rather than returning an empty lineage that would render as a blank row. + /// + [TestMethod] + public void TryTrimBelowArchiveRoot_LeafIsTheRoot_ReturnsFalseAndEmptyOutput() + { + // Arrange + var chain = new List + { + Segment("store", StorePath, "Mailbox - User"), + Segment("archive", ArchiveRoot, "Archive"), + }; + + // Act + bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot( + chain, + ArchiveRoot, + out var trimmed + ); + + // Assert + trimmedOk.Should().BeFalse(); + trimmed.Should().BeEmpty(); + } + + /// An empty chain has no archive-root node and therefore reports failure. + [TestMethod] + public void TryTrimBelowArchiveRoot_EmptyChain_ReturnsFalseAndEmptyOutput() + { + // Arrange + var chain = new List(); + + // Act + bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot( + chain, + ArchiveRoot, + out var trimmed + ); + + // Assert + trimmedOk.Should().BeFalse(); + trimmed.Should().BeEmpty(); + } + + /// + /// A single-element chain that IS the archive root is the degenerate form of the + /// leaf-is-the-root case and must behave identically. + /// + [TestMethod] + public void TryTrimBelowArchiveRoot_SingleElementChainIsTheRoot_ReturnsFalse() + { + // Arrange + var chain = new List + { + Segment("archive", ArchiveRoot, "Archive"), + }; + + // Act + bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot( + chain, + ArchiveRoot, + out var trimmed + ); + + // Assert + trimmedOk.Should().BeFalse(); + trimmed.Should().BeEmpty(); + } + + /// A root supplied with a trailing separator trims identically. + [TestMethod] + public void TryTrimBelowArchiveRoot_RootWithTrailingSeparator_ReturnsSegmentsAfterTheRoot() + { + // Arrange + var chain = FullChain(); + + // Act + bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot( + chain, + ArchiveRoot + "\\", + out var trimmed + ); + + // Assert + trimmedOk.Should().BeTrue(); + trimmed.Should().HaveCount(2); + trimmed[0].FolderPath.Should().Be(ClientsPath); + } + + /// + /// The #614 false-prefix case at chain level: a lineage under a sibling folder named + /// Archive2 must not be treated as passing through the root named Archive. + /// + [TestMethod] + public void TryTrimBelowArchiveRoot_FalsePrefixSiblingArchive2_ReturnsFalse() + { + // Arrange + var chain = new List + { + Segment("archive2", StorePath + "\\Archive2", "Archive2"), + Segment("archive2-clients", StorePath + "\\Archive2\\Clients", "Clients"), + }; + + // Act + bool trimmedOk = ArchiveChainProjection.TryTrimBelowArchiveRoot( + chain, + ArchiveRoot, + out var trimmed + ); + + // Assert + trimmedOk.Should().BeFalse("the separator-boundary test rejects a false prefix"); + trimmed.Should().BeEmpty(); + } + + /// Store node, archive root, one intermediate folder, and the leaf. + private static List FullChain() + { + return new List + { + Segment("store", StorePath, "Mailbox - User"), + Segment("archive", ArchiveRoot, "Archive"), + Segment("clients", ClientsPath, "Clients"), + Segment("acme", AcmePath, "Acme"), + }; + } + + private static FolderBreadcrumbSegment Segment( + string entryId, + string folderPath, + string displayName + ) + { + return new FolderBreadcrumbSegment( + new FolderTreeNodeKey("store-a", entryId, folderPath), + displayName, + folderPath, + false + ); + } + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs new file mode 100644 index 000000000..9b7338a4f --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs @@ -0,0 +1,176 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Unit tests for and its single member + /// ToDisplayStem, the lenient display projection introduced by issue #799. + /// The projection yields the archive-relative stem + /// only when the path is strictly under the configured root, and returns its input unchanged in + /// every other case. The boundary cases pinned here are the #614 cases the strict + /// already enforces, restated at the display boundary because + /// the fallback there is the opposite one: show the caller's own text rather than nothing. + /// Pure assertions only: no mocks, no COM, no filesystem, and no temporary file. + /// + [TestClass] + public sealed class ArchiveStemProjectionTests + { + private const string ArchiveRoot = "\\\\Mailbox - User\\Archive"; + private const string UnderRoot = "\\\\Mailbox - User\\Archive\\Clients\\Acme"; + private const string ExpectedStem = "Clients\\Acme"; + + /// The ordinary case: a path strictly under the root projects to its stem. + [TestMethod] + public void ToDisplayStem_PathStrictlyUnderRoot_ReturnsArchiveRelativeStem() + { + // Arrange, Act + var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, ArchiveRoot); + + // Assert + projected.Should().Be(ExpectedStem); + } + + /// + /// A path EQUAL to the root is not projectable: the strict contract reports success with an + /// empty stem, and an empty display row is worse than the full path, so the input is + /// returned unchanged. + /// + [TestMethod] + public void ToDisplayStem_PathEqualsRoot_ReturnsInputUnchanged() + { + // Arrange, Act + var projected = ArchiveStemProjection.ToDisplayStem(ArchiveRoot, ArchiveRoot); + + // Assert + projected + .Should() + .Be(ArchiveRoot, "an empty display row is worse than the unprojected path"); + } + + /// + /// The #614 false-prefix case. A sibling folder named Archive2 tested against a root ending + /// in Archive yields the character 2 at the root's length, which is not a separator, so the + /// path is NOT under the root and is not projected. + /// + [TestMethod] + public void ToDisplayStem_FalsePrefixSiblingArchive2_ReturnsInputUnchanged() + { + // Arrange + const string sibling = "\\\\Mailbox - User\\Archive2\\Clients"; + + // Act + var projected = ArchiveStemProjection.ToDisplayStem(sibling, ArchiveRoot); + + // Assert + projected.Should().Be(sibling, "the separator-boundary test rejects a false prefix"); + } + + /// A root supplied with one trailing separator projects identically. + [TestMethod] + public void ToDisplayStem_RootWithOneTrailingSeparator_ReturnsArchiveRelativeStem() + { + // Arrange, Act + var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, ArchiveRoot + "\\"); + + // Assert + projected.Should().Be(ExpectedStem); + } + + /// A root supplied with two trailing separators projects identically. + [TestMethod] + public void ToDisplayStem_RootWithTwoTrailingSeparators_ReturnsArchiveRelativeStem() + { + // Arrange, Act + var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, ArchiveRoot + "\\\\"); + + // Assert + projected.Should().Be(ExpectedStem); + } + + /// + /// AC4 of issue #799: an empty archive root leaves the input unchanged. The previous + /// per-site logic stripped one leading separator in this case, which produced a path that + /// was neither a valid full path nor a valid archive-relative stem. + /// + [TestMethod] + public void ToDisplayStem_EmptyRoot_ReturnsInputUnchanged() + { + // Arrange, Act + var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, string.Empty); + + // Assert + projected + .Should() + .Be(UnderRoot, "#799 AC4 removed the empty-root one-separator strip"); + } + + /// A whitespace-only root is treated exactly as an empty root. + [TestMethod] + public void ToDisplayStem_WhitespaceOnlyRoot_ReturnsInputUnchanged() + { + // Arrange, Act + var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, " "); + + // Assert + projected.Should().Be(UnderRoot); + } + + /// A null path is returned unchanged rather than throwing. + [TestMethod] + public void ToDisplayStem_NullPath_ReturnsNull() + { + // Arrange, Act + var projected = ArchiveStemProjection.ToDisplayStem(null, ArchiveRoot); + + // Assert + projected.Should().BeNull(); + } + + /// An empty path is returned unchanged rather than projected. + [TestMethod] + public void ToDisplayStem_EmptyPath_ReturnsInputUnchanged() + { + // Arrange, Act + var projected = ArchiveStemProjection.ToDisplayStem(string.Empty, ArchiveRoot); + + // Assert + projected.Should().Be(string.Empty); + } + + /// + /// Forward-slash separators on both parameters project exactly as backslash separators do, + /// because the underlying contract treats both characters as separators. + /// + [TestMethod] + public void ToDisplayStem_ForwardSlashSeparators_ReturnsArchiveRelativeStem() + { + // Arrange + const string root = "//Mailbox - User/Archive"; + const string path = "//Mailbox - User/Archive/Clients/Acme"; + + // Act + var projected = ArchiveStemProjection.ToDisplayStem(path, root); + + // Assert + projected.Should().Be("Clients/Acme"); + } + + /// + /// The prefix comparison is ordinal case-insensitive, so a mixed-case root still projects. + /// + [TestMethod] + public void ToDisplayStem_MixedCaseRoot_ReturnsArchiveRelativeStem() + { + // Arrange + const string mixedCaseRoot = "\\\\mailbox - USER\\aRcHiVe"; + + // Act + var projected = ArchiveStemProjection.ToDisplayStem(UnderRoot, mixedCaseRoot); + + // Assert + projected.Should().Be(ExpectedStem); + } + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs new file mode 100644 index 000000000..cd93bf1e5 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS.ReusableTypeClasses.SerializableNew.Concurrent.Observable; +using Outlook = Microsoft.Office.Interop.Outlook; +using OutlookFolder = Microsoft.Office.Interop.Outlook.Folder; +using OutlookFolders = Microsoft.Office.Interop.Outlook.Folders; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Tests for AC5 of issue #799: recent-folder entries pass through the same archive-stem + /// display projection as suggestion entries before they are displayed. Today an + /// archive-rooted recent entry is appended verbatim, so a recent selection renders as a full + /// store path beside suggestions that render as archive-relative stems. + /// + /// Every test seeds the recents list with one archive-rooted entry AND one already-relative + /// entry, so the projection is observable and the identity case is pinned by the same fixture. + /// The mocked-Outlook harness mirrors FolderRowTests; no live Outlook process, COM + /// server, or temporary file is used. + /// + /// + [TestClass] + public sealed class FolderPredictorRecentsProjectionTests + { + private const string ArchiveRootPath = "\\\\ArchiveRoot"; + private const string RootedRecent = "\\\\ArchiveRoot\\Recent\\One"; + private const string RootedRecentStem = "Recent\\One"; + private const string RelativeRecent = "Recent\\Two"; + private const string OutOfRootRecent = "\\\\OtherRoot\\Recent\\Three"; + private const string RecentsSeparator = "======= RECENT SELECTIONS ========"; + + /// + /// AC5 on the legacy string surface: the archive-rooted recent entry is projected to its + /// archive-relative stem, and the already-relative entry is left alone. + /// + [TestMethod] + public void FolderArray_RootedRecentEntry_IsProjectedToTheArchiveRelativeStem() + { + // Arrange + var predictor = PredictorWithRecents(RootedRecent, RelativeRecent); + + // Act + var folderArray = predictor.FolderArray; + + // Assert + folderArray.Should().Equal(RecentsSeparator, RootedRecentStem, RelativeRecent); + } + + /// + /// AC5 on the row-model surface: FolderRowArray projects the archive-rooted recent + /// entry identically, and still tags both entries as recent selections. + /// + [TestMethod] + public void FolderRowArray_RootedRecentEntry_IsProjectedToTheArchiveRelativeStem() + { + // Arrange + var predictor = PredictorWithRecents(RootedRecent, RelativeRecent); + + // Act + var rows = predictor.FolderRowArray; + + // Assert + rows.Select(r => r.Text) + .Should() + .Equal(RecentsSeparator, RootedRecentStem, RelativeRecent); + rows.Where(r => r.Kind == UtilitiesCS.FolderRowKind.Recent) + .Select(r => r.Text) + .Should() + .Equal(RootedRecentStem, RelativeRecent); + } + + /// + /// The text-parity contract documented on FolderRowArray is currently unasserted for + /// recents. Projecting one surface without the other would break it silently, so parity is + /// pinned here as its own criterion rather than as a side effect of the two tests above. + /// + [TestMethod] + public void FolderRowArray_AndFolderArray_AgreeOnRecentTextAfterProjection() + { + // Arrange + var predictor = PredictorWithRecents(RootedRecent, RelativeRecent); + + // Act + var folderArray = predictor.FolderArray; + var rows = predictor.FolderRowArray; + + // Assert + rows.Select(r => r.Text) + .Should() + .Equal(folderArray, "the row model mirrors FolderArray byte for byte"); + } + + /// + /// The projection is lenient: a recent entry that is NOT under the archive root is left + /// exactly as stored, because there is no stem to show and the full path is the only + /// meaningful text. + /// + [TestMethod] + public void FolderArray_OutOfRootRecentEntry_IsLeftUnchanged() + { + // Arrange + var predictor = PredictorWithRecents(RootedRecent, RelativeRecent, OutOfRootRecent); + + // Act + var folderArray = predictor.FolderArray; + + // Assert + folderArray + .Should() + .Equal(RecentsSeparator, RootedRecentStem, RelativeRecent, OutOfRootRecent); + } + + private static UtilitiesCS.FolderPredictor PredictorWithRecents(params string[] recents) + { + var archiveRoot = CreateFolder( + ArchiveRootPath, + new Dictionary() + ); + var app = CreateApplication( + new Dictionary { ["ArchiveRoot"] = archiveRoot.Object } + ); + var globals = CreateGlobals(app, archiveRoot.Object, recents); + return new UtilitiesCS.FolderPredictor(globals.Object); + } + + // ---- Mocked-Outlook harness (mirrors FolderRowTests) ---- + + private static Mock CreateGlobals( + Mock app, + OutlookFolder rootFolder, + IEnumerable recents + ) + { + var autoFile = new Mock(); + autoFile.SetupGet(x => x.RecentsList).Returns(new SloLinkedList(recents)); + + var olObjects = new Mock(); + olObjects.SetupGet(x => x.App).Returns(app.Object); + olObjects.SetupGet(x => x.ArchiveRootPath).Returns(rootFolder.FolderPath); + olObjects.SetupGet(x => x.Root).Returns(rootFolder); + + var globals = new Mock(); + globals.SetupGet(x => x.AF).Returns(autoFile.Object); + globals.SetupGet(x => x.Ol).Returns(olObjects.Object); + return globals; + } + + private static Mock CreateApplication( + IDictionary rootFolders + ) + { + var app = new Mock(); + var nameSpace = new Mock(); + nameSpace.SetupGet(x => x.Folders).Returns(CreateFoldersCollection(rootFolders).Object); + app.SetupGet(x => x.Session).Returns(nameSpace.Object); + return app; + } + + private static Mock CreateFolder( + string folderPath, + IDictionary childFolders + ) + { + var folder = new Mock(); + folder.SetupGet(x => x.Name).Returns(GetLeafName(folderPath)); + folder.SetupGet(x => x.FolderPath).Returns(folderPath); + folder + .SetupGet(x => x.Folders) + .Returns( + CreateFoldersCollection( + childFolders ?? new Dictionary() + ).Object + ); + return folder; + } + + private static Mock CreateFoldersCollection( + IDictionary foldersByName + ) + { + var folders = new Mock(); + var enumerableItems = foldersByName?.Values?.ToArray() ?? Array.Empty(); + var collection = new ArrayList(enumerableItems); + + folders + .Setup(x => x[It.IsAny()]) + .Returns(key => + { + if ( + key is string name + && foldersByName.TryGetValue(name, out OutlookFolder folder) + ) + { + return folder; + } + return null; + }); + folders.Setup(x => x.GetEnumerator()).Returns(() => collection.GetEnumerator()); + return folders; + } + + private static string GetLeafName(string folderPath) + { + return folderPath.Split('\\').Last(segment => !string.IsNullOrWhiteSpace(segment)); + } + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs index 20f76d3e4..e62db7a6a 100644 --- a/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs @@ -70,11 +70,12 @@ public sealed class OutlookFolderHierarchyProviderTests ); [TestMethod] - public async Task GetAncestorChainAsync_HappyPath_ReturnsRootToLeafSegments() + public async Task GetAncestorChainAsync_WithRootAccessor_ReturnsSegmentsBelowTheArchiveRoot_HappyPath() { - // Arrange + // Arrange: configured as production configures it, with a root accessor (#799 AC1). var provider = new OutlookFolderHierarchyProvider( - ServiceReturning(BuildSnapshot()).Object + ServiceReturning(BuildSnapshot()).Object, + () => "\\Root" ); // Act @@ -84,7 +85,7 @@ public async Task GetAncestorChainAsync_HappyPath_ReturnsRootToLeafSegments() chain .Select(s => s.FolderPath) .Should() - .Equal("\\Root", "\\Root\\Clients", "\\Root\\Clients\\Acme"); + .Equal("\\Root\\Clients", "\\Root\\Clients\\Acme"); chain.Last().Key.Should().Be(AcmeKey); chain.Last().HasChildren.Should().BeFalse(); chain.First().HasChildren.Should().BeTrue(); diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs new file mode 100644 index 000000000..11fab24e5 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs @@ -0,0 +1,346 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Unit tests for the issue #799 seams on : the + /// archive-root chain trim (AC1, AC2) and the label-absence classification (AC7). The folder + /// tree is a hand-built returned by a Moq + /// , and diagnostics are observed through the provider's + /// injected error sink rather than by attaching a log4net appender, so no test mutates the + /// process-global logger repository. No live Outlook process, COM, or temporary file is used. + /// + [TestClass] + public sealed class OutlookFolderHierarchyProviderTrimTests + { + private const string StorePath = "\\\\Mailbox - User"; + private const string ArchivePath = "\\\\Mailbox - User\\Archive"; + private const string ProjectsPath = "\\\\Mailbox - User\\Archive\\Projects"; + private const string AlphaPath = "\\\\Mailbox - User\\Archive\\Projects\\Alpha"; + private const string InboxPath = "\\\\Mailbox - User\\Inbox"; + private const string InboxProjectsPath = "\\\\Mailbox - User\\Inbox\\Projects"; + private const string InboxAlphaPath = "\\\\Mailbox - User\\Inbox\\Projects\\Alpha"; + private const string PresentedStem = "Projects\\Alpha"; + private const string MissingStem = "Missing\\Folder"; + + private static readonly FolderTreeNodeKey StoreKey = Key("store", StorePath); + private static readonly FolderTreeNodeKey ArchiveKey = Key("archive", ArchivePath); + private static readonly FolderTreeNodeKey ProjectsKey = Key("projects", ProjectsPath); + private static readonly FolderTreeNodeKey AlphaKey = Key("alpha", AlphaPath); + private static readonly FolderTreeNodeKey InboxKey = Key("inbox", InboxPath); + private static readonly FolderTreeNodeKey InboxProjectsKey = Key( + "inbox-projects", + InboxProjectsPath + ); + private static readonly FolderTreeNodeKey InboxAlphaKey = Key( + "inbox-alpha", + InboxAlphaPath + ); + + /// + /// AC1: with a root accessor configured, the presented lineage begins at the first segment + /// BELOW the archive root. Neither the store node nor the archive-root node may appear. + /// + [TestMethod] + public async Task GetAncestorChainAsync_WithRootAccessor_ReturnsSegmentsBelowTheArchiveRoot() + { + // Arrange + var provider = ProviderOver(ArchiveSnapshot(), () => ArchivePath); + + // Act + var chain = await provider.GetAncestorChainAsync(AlphaKey, CancellationToken.None); + + // Assert + chain.Select(s => s.FolderPath).Should().Equal(ProjectsPath, AlphaPath); + chain + .Select(s => s.FolderPath) + .Should() + .NotContain(StorePath, "the mailbox segment is never presented"); + chain + .Select(s => s.FolderPath) + .Should() + .NotContain(ArchivePath, "the Archive segment is never presented"); + } + + /// + /// Constructing the provider WITHOUT a root accessor is the effective off switch: the chain + /// is returned exactly as the snapshot walk produced it. + /// + [TestMethod] + public async Task GetAncestorChainAsync_WithoutRootAccessor_ReturnsTheUntrimmedChain() + { + // Arrange + var provider = new OutlookFolderHierarchyProvider( + ServiceReturning(ArchiveSnapshot()).Object + ); + + // Act + var chain = await provider.GetAncestorChainAsync(AlphaKey, CancellationToken.None); + + // Assert + chain + .Select(s => s.FolderPath) + .Should() + .Equal(StorePath, ArchivePath, ProjectsPath, AlphaPath); + } + + /// + /// AC2: a resolved chain that does not pass through the archive root is logged exactly once + /// and yields an empty segment list, which routes each surface into its existing + /// single-segment fallback. The emitted text must name the archive root so the failure is + /// diagnosable from the log alone. + /// + [TestMethod] + public async Task GetAncestorChainAsync_ChainMissesArchiveRoot_LogsErrorAndReturnsEmpty() + { + // Arrange + var errors = new List(); + var provider = ProviderOver(ArchiveSnapshot(), () => StorePath + "\\Elsewhere"); + provider.ErrorSink = message => errors.Add(message); + + // Act + var chain = await provider.GetAncestorChainAsync(AlphaKey, CancellationToken.None); + + // Assert + chain.Should().BeEmpty("the caller falls back to single-segment rendering"); + errors.Should().ContainSingle("the AC2 diagnostic is emitted exactly once"); + errors[0].Should().Contain(StorePath + "\\Elsewhere"); + } + + /// + /// When the LEAF is the archive root itself there is nothing below it to render, so the + /// same AC2 diagnostic and empty result apply. + /// + [TestMethod] + public async Task GetAncestorChainAsync_LeafIsTheArchiveRoot_LogsErrorAndReturnsEmpty() + { + // Arrange + var errors = new List(); + var provider = ProviderOver(ArchiveSnapshot(), () => ArchivePath); + provider.ErrorSink = message => errors.Add(message); + + // Act + var chain = await provider.GetAncestorChainAsync(ArchiveKey, CancellationToken.None); + + // Assert + chain.Should().BeEmpty(); + errors.Should().ContainSingle(); + } + + /// + /// The accessor is a delegate precisely because the underlying archive-root property throws + /// when the root is unresolvable. A throwing accessor means "no trim configured" and must + /// not propagate, because two of the three construction sites are outside any try block. + /// + [TestMethod] + public async Task GetAncestorChainAsync_RootAccessorThrows_DoesNotThrowAndReturnsTheUntrimmedChain() + { + // Arrange + var provider = ProviderOver( + ArchiveSnapshot(), + () => throw new InvalidOperationException("archive root unresolvable") + ); + + // Act + var chain = await provider.GetAncestorChainAsync(AlphaKey, CancellationToken.None); + + // Assert + chain + .Select(s => s.FolderPath) + .Should() + .Equal(StorePath, ArchivePath, ProjectsPath, AlphaPath); + } + + /// + /// AC7 logging half: an unresolvable label is reported once per label per provider + /// instance, not once per render, and the label is classified as absent. + /// + [TestMethod] + public async Task ResolveLeafKeyAsync_SameAbsentLabelTwice_EmitsOneErrorAndReportsAbsence() + { + // Arrange + var errors = new List(); + var provider = ProviderOver(ArchiveSnapshot(), () => ArchivePath); + provider.ErrorSink = message => errors.Add(message); + + // Act + await provider.ResolveLeafKeyAsync(MissingStem, CancellationToken.None); + await provider.ResolveLeafKeyAsync(MissingStem, CancellationToken.None); + + // Assert + errors + .Should() + .ContainSingle("the gate is once per label per session, not per render"); + provider.IsAbsentLabel(MissingStem).Should().BeTrue(); + } + + /// + /// Decision D-B restricts the AC7 suppression signal to the ZERO-candidate cause. An + /// ambiguous label is still logged, but it is not absent: the folder exists, and more than + /// one candidate matched. + /// + [TestMethod] + public async Task ResolveLeafKeyAsync_AmbiguousLabel_EmitsOneErrorAndDoesNotReportAbsence() + { + // Arrange + var errors = new List(); + var provider = ProviderOver(DecoySnapshot(), () => ArchivePath); + provider.ErrorSink = message => errors.Add(message); + + // Act + var resolved = await provider.ResolveLeafKeyAsync( + PresentedStem, + CancellationToken.None + ); + + // Assert + resolved.Should().BeNull("an ambiguous stem is never resolved to either candidate"); + errors.Should().ContainSingle(); + provider + .IsAbsentLabel(PresentedStem) + .Should() + .BeFalse("ambiguity is not absence; the folder does exist"); + } + + /// + /// The absence signal must RESET, or a label that becomes resolvable after a snapshot + /// refresh would stay suppressed for the life of the viewer. The service returns a snapshot + /// missing the leaf on the first call and the complete snapshot on the second. + /// + [TestMethod] + public async Task ResolveLeafKeyAsync_AbsentThenResolvableLabel_ClearsTheAbsenceReport() + { + // Arrange + var service = new Mock(); + service + .SetupSequence(s => + s.GetSnapshotAsync(It.IsAny(), It.IsAny()) + ) + .ReturnsAsync(SnapshotWithoutLeaf()) + .ReturnsAsync(ArchiveSnapshot()); + var provider = new OutlookFolderHierarchyProvider(service.Object, () => ArchivePath); + + // Act + await provider.ResolveLeafKeyAsync(PresentedStem, CancellationToken.None); + bool absentBefore = provider.IsAbsentLabel(PresentedStem); + var resolved = await provider.ResolveLeafKeyAsync( + PresentedStem, + CancellationToken.None + ); + + // Assert + absentBefore.Should().BeTrue("the first snapshot had no node for the label"); + resolved.Should().Be(AlphaKey); + provider + .IsAbsentLabel(PresentedStem) + .Should() + .BeFalse("the label resolved, so the suppression signal must clear"); + } + + private static OutlookFolderHierarchyProvider ProviderOver( + FolderTreeSnapshot snapshot, + Func archiveRootAccessor + ) + { + return new OutlookFolderHierarchyProvider( + ServiceReturning(snapshot).Object, + archiveRootAccessor + ); + } + + private static Mock ServiceReturning(FolderTreeSnapshot snapshot) + { + var service = new Mock(); + service + .Setup(s => + s.GetSnapshotAsync(It.IsAny(), It.IsAny()) + ) + .ReturnsAsync(snapshot); + return service; + } + + /// Store, Archive, Projects, Alpha: a store-rooted three-level Archive chain. + private static FolderTreeSnapshot ArchiveSnapshot() + { + return new FolderTreeSnapshot( + new[] { StoreKey }, + new[] + { + Node(StoreKey, "Mailbox - User", null, ArchiveKey), + Node(ArchiveKey, "Archive", StoreKey, ProjectsKey), + Node(ProjectsKey, "Projects", ArchiveKey, AlphaKey), + Node(AlphaKey, "Alpha", ProjectsKey), + } + ); + } + + /// The Archive chain with the Alpha leaf absent, so the stem has zero candidates. + private static FolderTreeSnapshot SnapshotWithoutLeaf() + { + return new FolderTreeSnapshot( + new[] { StoreKey }, + new[] + { + Node(StoreKey, "Mailbox - User", null, ArchiveKey), + Node(ArchiveKey, "Archive", StoreKey, ProjectsKey), + Node(ProjectsKey, "Projects", ArchiveKey), + } + ); + } + + /// + /// The Archive chain plus an Inbox chain whose leaf shares the last two segments, so a + /// suffix match on the presented stem is ambiguous rather than absent. + /// + private static FolderTreeSnapshot DecoySnapshot() + { + return new FolderTreeSnapshot( + new[] { StoreKey }, + new[] + { + Node(StoreKey, "Mailbox - User", null, ArchiveKey, InboxKey), + Node(ArchiveKey, "Archive", StoreKey, ProjectsKey), + Node(ProjectsKey, "Projects", ArchiveKey, AlphaKey), + Node(AlphaKey, "Alpha", ProjectsKey), + Node(InboxKey, "Inbox", StoreKey, InboxProjectsKey), + Node(InboxProjectsKey, "Projects", InboxKey, InboxAlphaKey), + Node(InboxAlphaKey, "Alpha", InboxProjectsKey), + } + ); + } + + private static FolderTreeNodeKey Key(string entryId, string folderPath) + { + return new FolderTreeNodeKey("store-a", entryId, folderPath); + } + + private static FolderTreeSnapshotNode Node( + FolderTreeNodeKey key, + string displayName, + FolderTreeNodeKey parentKey, + params FolderTreeNodeKey[] childKeys + ) + { + return new FolderTreeSnapshotNode( + key, + displayName, + key.StoreId, + key.EntryId, + parentKey, + key.FolderPath, + displayName, + childKeys, + false, + string.Empty + ); + } + } +} diff --git a/UtilitiesCS.Test/UtilitiesCS.Test.csproj b/UtilitiesCS.Test/UtilitiesCS.Test.csproj index a4c2e8c0d..9702d6a98 100644 --- a/UtilitiesCS.Test/UtilitiesCS.Test.csproj +++ b/UtilitiesCS.Test/UtilitiesCS.Test.csproj @@ -304,6 +304,10 @@ + + + + diff --git a/UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs b/UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs new file mode 100644 index 000000000..6744074c0 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs @@ -0,0 +1,93 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Trims a breadcrumb ancestor chain so that only the lineage BELOW the configured Outlook + /// archive root is presented (#799 AC1 and AC2). + /// + /// Pure by construction: no filesystem, network, COM, logging, or environment access. Segment + /// instances are passed through by reference; no segment is rebuilt or reordered. + /// + /// + public static class ArchiveChainProjection + { + /// + /// Finds the archive-root node in and yields the remainder of the + /// chain that follows it. + /// + /// The archive-root node is the FIRST chain index whose segment + /// is the root itself, detected as + /// + /// returning true with an EMPTY stem, which is exactly the path-equals-root case. The + /// method returns the segments after that index. + /// + /// + /// It returns false when no such index exists, and also when that index is the LAST + /// element: the leaf is then the archive root itself and there is nothing below it to + /// render. It also returns false for a null or empty chain and for a null, empty, or + /// whitespace-only root. + /// + /// + /// The root-to-leaf ancestor chain. Null returns false. + /// + /// The configured archive root. Null, empty, and whitespace-only roots return false. + /// + /// + /// The segments below the archive root on success; an empty list on every failing path. + /// + /// True when a proper lineage below the archive root exists; otherwise false. + public static bool TryTrimBelowArchiveRoot( + IReadOnlyList? chain, + string? archiveRoot, + out IReadOnlyList trimmed + ) + { + trimmed = Array.Empty(); + + // The null half of this guard is required, not defensive: ArchiveStemContract declares + // both inputs as non-nullable string, so passing archiveRoot through without narrowing + // is CS8604 under the nullable gate. A whitespace-only root is rejected by the + // contract's own guard, so it needs no separate test here. + if (chain is null || chain.Count == 0 || archiveRoot is null) + { + return false; + } + + for (int index = 0; index < chain.Count; index++) + { + bool isRootNode = + ArchiveStemContract.TryMakeArchiveRelative( + chain[index].FolderPath, + archiveRoot, + out var stem + ) + && stem.Length == 0; + + if (!isRootNode) + { + continue; + } + + // The leaf IS the archive root: there is nothing below it to render. + if (index == chain.Count - 1) + { + return false; + } + + var below = new FolderBreadcrumbSegment[chain.Count - index - 1]; + for (int offset = 0; offset < below.Length; offset++) + { + below[offset] = chain[index + 1 + offset]; + } + + trimmed = below; + return true; + } + + return false; + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs b/UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs new file mode 100644 index 000000000..f28579faf --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs @@ -0,0 +1,63 @@ +#nullable enable + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Lenient DISPLAY projection of a full Outlook folder path onto its archive-relative stem. + /// + /// The projection returns the archive-relative stem when, and only when, the path is strictly + /// under the configured archive root, and returns the input UNCHANGED in every other case: + /// a path equal to the root, a path outside the root, a null or empty root, and a + /// whitespace-only root. That last case is the behaviour change AC4 of issue #799 requires; + /// the previous per-site logic stripped one leading separator when the root was empty. + /// + /// + /// This is a separate type rather than an additional overload on + /// because that contract is a hard boundary: it yields an + /// empty string on failure and never passes its input through, which is precisely the + /// invariant #614 created it to enforce. Every display site needs the opposite fallback — + /// show the caller's own text rather than nothing — so adding a lenient overload beside the + /// strict one would blur the boundary the contract exists to defend. + /// + /// + /// Pure by construction: no filesystem, network, COM, logging, or environment access. + /// + /// + public static class ArchiveStemProjection + { + /// + /// Projects onto its archive-relative stem for display. + /// + /// The candidate full Outlook path. Null is returned unchanged. + /// + /// The configured archive root. Null, empty, and whitespace-only roots disable the + /// projection and the input is returned unchanged. + /// + /// + /// The archive-relative stem when the path is strictly under the root; otherwise + /// unchanged. + /// + public static string? ToDisplayStem(string? folderPath, string? archiveRoot) + { + // The null guard is required, not defensive: ArchiveStemContract.TryMakeArchiveRelative + // declares both inputs as non-nullable string, so passing either parameter through + // without narrowing is CS8604 under the nullable gate. + if (folderPath is null || archiveRoot is null) + { + return folderPath; + } + + // A zero-length stem is the path-equals-root case, which the contract reports as true. + // An empty display row is worse than the full path, so it is not projected. + if ( + ArchiveStemContract.TryMakeArchiveRelative(folderPath, archiveRoot, out var stem) + && stem.Length > 0 + ) + { + return stem; + } + + return folderPath; + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs b/UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs index c94b93972..7aa449d07 100644 --- a/UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs +++ b/UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs @@ -9,6 +9,7 @@ using System.Windows.Forms; using Microsoft.Office.Interop.Outlook; using UtilitiesCS; +using UtilitiesCS.OutlookObjects.Folder; using Outlook = Microsoft.Office.Interop.Outlook; namespace UtilitiesCS @@ -790,7 +791,11 @@ public void AddRecents(ref List folderList) // internal if (_globals.AF.RecentsList.Count > 0) { folderList.Add("======= RECENT SELECTIONS ========"); - folderList.AddRange(_globals.AF.RecentsList); + // AC5: recents share the suggestion projection; the ! is required (else CS8620). + var r = _globals.Ol.ArchiveRootPath; + folderList.AddRange( + _globals.AF.RecentsList.Select(x => ArchiveStemProjection.ToDisplayStem(x, r)!) + ); } } @@ -847,17 +852,9 @@ private void AddSuggestionRows(List rows) private string ProjectSuggestionPath(string folderPath) { - if (_globals is null) - { - return folderPath; - } - - var archivePrefix = _globals.Ol.ArchiveRootPath + "\\"; - return - folderPath.StartsWith(archivePrefix, StringComparison.OrdinalIgnoreCase) - && folderPath.Length > archivePrefix.Length - ? folderPath.Substring(archivePrefix.Length) - : folderPath; + // Null-forgiving: ToDisplayStem returns null only for a null input, which this + // non-nullable parameter excludes; unsuppressed the return is CS8603 (#799 AC4). + return ArchiveStemProjection.ToDisplayStem(folderPath, _globals?.Ol.ArchiveRootPath)!; } // Row-model mirror of AddRecents: the RECENT SELECTIONS separator (Separator, no score) @@ -874,9 +871,13 @@ private void AddRecentRows(List rows) null ) ); + // AC5 row-model mirror: projecting one surface only would break the documented + // text-parity contract. Null-forgiving as in AddRecents (else CS8604 at FolderRow). + var root = _globals.Ol.ArchiveRootPath; foreach (var recent in _globals.AF.RecentsList) { - rows.Add(new FolderRow(recent, FolderRowKind.Recent, null)); + var text = ArchiveStemProjection.ToDisplayStem(recent, root)!; + rows.Add(new FolderRow(text, FolderRowKind.Recent, null)); } } } @@ -954,14 +955,12 @@ public string GetOlSubpath(string path, string olAncestor, bool includeChildren) { if (includeChildren) { - if (olAncestor.EndsWith('\\'.ToString())) - { - return path.Substring(olAncestor.Length); - } - else - { - return path.Substring(olAncestor.Length + 1); - } + // #799 verified prefix removal: a non-prefix path now yields the input instead of a + // garbage substring, and a path no longer than the ancestor no longer throws. The + // contract is root-agnostic despite its parameter name. + return ArchiveStemContract.TryMakeArchiveRelative(path, olAncestor, out var stem) + ? stem + : path; } else { diff --git a/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs b/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs index 6a867ec2e..bfb2e351d 100644 --- a/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs +++ b/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -7,12 +8,39 @@ namespace UtilitiesCS.OutlookObjects.Folder { + /// + /// Reports whether a folder label was classified as ABSENT from the folder snapshot, meaning + /// resolution found ZERO candidate nodes for it (#799 AC7). An ambiguous label, for which + /// resolution found more than one candidate, is deliberately NOT absent. + /// + /// This is a separate, small interface rather than a fourth member on + /// for two reasons. net48 has no default interface + /// members, so a fourth member would break every implementer. Decisively, every breadcrumb + /// router test constructs a strict Mock<IFolderHierarchyProvider>, which would + /// throw the first time production called an un-set-up new member; because a strict mock is + /// simply not an , the consuming cast yields null and + /// the AC7 suppression stays inert in every existing router test. + /// + /// + public interface IFolderLabelAbsenceReport + { + /// + /// Reports whether was classified as absent from the + /// snapshot by the most recent resolution attempt against this instance. + /// + /// The presented folder path or label. + /// True when the label resolved to zero candidates; otherwise false. + bool IsAbsentLabel(string folderPath); + } + /// /// Host-neutral facade over that projects the cached /// into breadcrumb segments. Adds no COM code and is not /// coverage-exempt; the live Outlook query stays isolated behind the injected service interface. /// - public sealed class OutlookFolderHierarchyProvider : IFolderHierarchyProvider + public sealed class OutlookFolderHierarchyProvider + : IFolderHierarchyProvider, + IFolderLabelAbsenceReport { private static readonly log4net.ILog logger = log4net.LogManager.GetLogger( System.Reflection.MethodBase.GetCurrentMethod().DeclaringType @@ -20,16 +48,58 @@ public sealed class OutlookFolderHierarchyProvider : IFolderHierarchyProvider private readonly IOutlookFolderTreeService _treeService; + // Two DISTINCT per-instance structures (#799 D6), not one. The reported set only ever gains + // entries, which is what makes the AC7 diagnostic once per label per session rather than + // once per render. The absent set also LOSES entries, because a label that becomes + // resolvable after a snapshot refresh must stop being suppressed. ConcurrentDictionary + // rather than HashSet because ResolveLeafKeyAsync awaits AcquireSnapshotAsync and its + // continuations are not guaranteed to resume on one thread; per-instance rather than static + // because a static set is process-wide mutable state shared across viewers and across test + // methods in a single assembly. + private readonly ConcurrentDictionary _reportedLabels = new( + StringComparer.OrdinalIgnoreCase + ); + + private readonly ConcurrentDictionary _absentLabels = new( + StringComparer.OrdinalIgnoreCase + ); + /// /// Creates a provider over the supplied folder-tree service. /// /// The cached snapshot service. Required. + /// + /// Optional lazy accessor for the configured Outlook archive root, used to trim the + /// ancestor chain to the lineage below that root (#799 AC1, AC2). It is a delegate rather + /// than a value because the underlying archive-root property throws when the root is + /// unresolvable, and reading it eagerly at construction would create a new throw site at + /// every construction site. A null accessor is the effective off switch and leaves the + /// chain untrimmed. + /// /// is null. - public OutlookFolderHierarchyProvider(IOutlookFolderTreeService treeService) + public OutlookFolderHierarchyProvider( + IOutlookFolderTreeService treeService, + System.Func? archiveRootAccessor = null + ) { _treeService = treeService ?? throw new ArgumentNullException(nameof(treeService)); + ArchiveRootAccessor = archiveRootAccessor; } + /// + /// The lazy archive-root accessor supplied at construction, or null when no trim is + /// configured. Stored as a get-only auto-property rather than a private readonly field so + /// that the seam-only intermediate state raises no CS0414 assigned-but-never-read warning. + /// + internal System.Func? ArchiveRootAccessor { get; } + + /// + /// Injected diagnostic sink that tests observe INSTEAD of attaching a log4net appender, so + /// no test mutates the process-global logger repository. Production leaves it null and the + /// provider logs through its own log4net logger only. + /// + internal System.Action? ErrorSink { get; set; } + /// public async Task> GetAncestorChainAsync( FolderTreeNodeKey leafKey, @@ -38,7 +108,66 @@ CancellationToken cancellationToken { var snapshot = await AcquireSnapshotAsync(cancellationToken).ConfigureAwait(false); var chain = FolderTreeSnapshotQueries.GetAncestorChain(snapshot, leafKey); - return MapNodes(chain); + var mapped = MapNodes(chain); + + // The trim runs AFTER the snapshot walk and BEFORE the caller sees the chain, so row + // order, banner placement and the trash pseudo-row are all untouched (#799 AC1, AC2). + string? archiveRoot = TryReadArchiveRoot(); + if (string.IsNullOrWhiteSpace(archiveRoot)) + { + return mapped; + } + + if ( + ArchiveChainProjection.TryTrimBelowArchiveRoot(mapped, archiveRoot, out var trimmed) + ) + { + return trimmed; + } + + // AC2: a chain that never reaches the archive root is a diagnosable condition. Returning + // an empty list routes the Efc surface into the empty-chain single-segment fallback and + // the QuickFiler surface into its existing scored fallback. + EmitError( + $"Resolved ancestor chain does not pass through the configured archive root '{archiveRoot}'; falling back to single-segment rendering." + ); + return Array.Empty(); + } + + /// + /// Reads the configured archive root through the injected accessor, treating a null + /// accessor and any exception from it alike as "no trim configured". The accessor is lazy + /// and its faults are swallowed here because the underlying archive-root property throws + /// when the root is unresolvable and two of the three construction sites are outside any + /// try block, so a propagating read would create a new throw site at those call sites. + /// + private string? TryReadArchiveRoot() + { + var accessor = ArchiveRootAccessor; + if (accessor is null) + { + return null; + } + + try + { + return accessor(); + } + catch (Exception exception) + { + logger.Debug( + "The archive-root accessor threw; leaving the ancestor chain untrimmed.", + exception + ); + return null; + } + } + + /// Emits one diagnostic through log4net and through the injected test sink. + private void EmitError(string message) + { + logger.Error(message); + ErrorSink?.Invoke(message); } /// @@ -73,12 +202,25 @@ CancellationToken cancellationToken if (match != null) { + // The exact-path route returns before the suffix pass is ever reached, so the AC7 + // absence signal has to be cleared here as well as on the suffix success route. + _absentLabels.TryRemove(folderPath, out _); return match.Key; } - return ResolveByUniqueSuffix(snapshot, folderPath); + var resolved = ResolveByUniqueSuffix(snapshot, folderPath); + if (resolved != null) + { + _absentLabels.TryRemove(folderPath, out _); + } + + return resolved; } + /// + public bool IsAbsentLabel(string folderPath) => + !string.IsNullOrWhiteSpace(folderPath) && _absentLabels.ContainsKey(folderPath); + /// /// Second resolution pass for a relative stem such as Projects\Alpha, which the /// QuickFiler surface presents in place of a store-qualified path. Accepts a node whose @@ -86,8 +228,12 @@ CancellationToken cancellationToken /// exactly one node qualifies: uniqueness is the safety property that prevents filing into /// a same-named folder under a different root. Zero or multiple candidates return null, so /// the caller keeps today's single-segment fallback rendering. + /// + /// An instance member rather than a static one because the AC7 log gate and the absence + /// classification are both per-provider-instance state (#799 D6). + /// /// - private static FolderTreeNodeKey? ResolveByUniqueSuffix( + private FolderTreeNodeKey? ResolveByUniqueSuffix( FolderTreeSnapshot snapshot, string folderPath ) @@ -105,11 +251,26 @@ string folderPath return candidates[0].Key; } - logger.Error( - candidates.Length == 0 - ? $"No snapshot node path ends with '{suffix}'; leaving '{folderPath}' unresolved." - : $"Multiple snapshot node paths end with '{suffix}'; leaving '{folderPath}' unresolved." - ); + if (candidates.Length == 0) + { + // AC7, restricted by decision D-B to the ZERO-candidate cause: the label is absent + // from the snapshot. Ambiguity is not absence — the folder does exist — so the + // multiple-candidate cause deliberately leaves this signal untouched. + _absentLabels[folderPath] = 0; + } + + // TryAdd is the AC7 log gate: a label already reported by this provider instance emits + // nothing further, so the diagnostic is once per label per session rather than once per + // render. The two causes stay distinguishable in the message text. + if (_reportedLabels.TryAdd(folderPath, 0)) + { + EmitError( + candidates.Length == 0 + ? $"No snapshot node path ends with '{suffix}'; leaving '{folderPath}' unresolved." + : $"Multiple snapshot node paths end with '{suffix}'; leaving '{folderPath}' unresolved." + ); + } + return null; } diff --git a/UtilitiesCS/UtilitiesCS.csproj b/UtilitiesCS/UtilitiesCS.csproj index 0250500d9..75b0d7294 100644 --- a/UtilitiesCS/UtilitiesCS.csproj +++ b/UtilitiesCS/UtilitiesCS.csproj @@ -621,6 +621,8 @@ + + diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/code-review.2026-09-07T20-30.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/code-review.2026-09-07T20-30.md new file mode 100644 index 000000000..26d7b370a --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/code-review.2026-09-07T20-30.md @@ -0,0 +1,243 @@ +# Code Review — Issue #799 breadcrumb lineage below archive root and suggestion path consistency + +- Date: 2026-09-07T20-30 +- Branch: `bug/breadcrumb-lineage-below-archive-root-799` +- Base: `2085504e6daaa11b9ec0a8857e7777cf9b10143f` — Head: `7db935b791cf81e0f6df00fef6ef084a8a7a2b4c` +- Scope: the full branch diff against the resolved base, twenty paths. +- Verdict: **ACCEPT. 0 Blocking findings, 7 non-blocking findings.** + +## Overall assessment + +The design is the right one. Placing the archive-root trim inside +`OutlookFolderHierarchyProvider.GetAncestorChainAsync` puts a single change at the one seam both breadcrumb +surfaces route through, which satisfies AC1 and AC2 on both surfaces without touching any of the six files a +concurrent sibling item owns. The two new types are genuinely pure, small, fully documented and fully covered. +The duplication AC4 was written to eliminate is actually eliminated rather than merely wrapped: both former +copies of the stripping expression collapse to one-line delegations to a single shared member, and the empty-root +one-separator behaviour that made them diverge is gone from both. + +The change is also unusually well disciplined about not doing more than it should. Three of the seven candidate +stripping sites are deliberately left, each with a stated reason, and the two persisted-data wrapper loaders are +recorded as a real but separate defect rather than folded in. Every null-forgiving `!` in the diff carries an +in-code comment naming the exact diagnostic it suppresses. Every deviation from the specification's own prose is +recorded by name with its reason. + +The findings below are all improvements, not defects that block merge. + +## Design and structure + +**Positive: the lazy accessor is the correct shape and its failure mode is handled.** The archive-root property +throws `InvalidOperationException` when the root is unresolvable — confirmed at +`TaskMaster/AppGlobals/AppOlObjects.cs:260-270`, whose getter calls `ResolveValidatedArchiveRootPath()`. Supplying +it as `System.Func?` rather than a `string` means no construction site gains a throw site, and +`TryReadArchiveRoot` additionally swallows an accessor fault back to "no trim configured" with a `logger.Debug` +and an in-code justification. `GetAncestorChainAsync_RootAccessorThrows_DoesNotThrowAndReturnsTheUntrimmedChain` +pins it. The optional parameter with a `null` default is also a real off switch and keeps roughly twenty existing +provider constructions compiling unchanged. + +**Positive: the AC7 interface is separated for a stated, verifiable reason.** Adding a fourth member to +`IFolderHierarchyProvider` would break every implementer on net48 (no default interface members) and would make +every `Mock(MockBehavior.Strict)` throw the first time production called it. Declaring +`IFolderLabelAbsenceReport` separately and obtaining it with `provider as IFolderLabelAbsenceReport` at +`QuickFiler/Controllers/BreadcrumbBridgeRouter.cs:56` makes suppression inert in every existing router test +without a single edit to those tests. The reviewer confirmed the mechanism: `IFolderHierarchyProvider.cs` carries +zero hunks, and both production construction sites hand the concrete provider straight through with no adapter +(`QfcItemController.BreadcrumbWiring.cs:22-26`, `EfcFormController.cs:1053-1056`, +`QuickFiler/Viewers/ItemViewer.Breadcrumb.cs:46-73`). + +**Positive: the AC7 gate uses two distinct sets rather than one, and the distinction is load-bearing.** +`_reportedLabels` only ever gains entries, which is what makes the diagnostic once per label per provider +instance. `_absentLabels` also loses entries, cleared on both the exact-path route +(`OutlookFolderHierarchyProvider.cs`, after the `match != null` branch) and the suffix-success route, so a label +that becomes resolvable after a snapshot refresh stops being suppressed. Collapsing these into one set would have +been the obvious simplification and would have been wrong. It is pinned by +`ResolveLeafKeyAsync_AbsentThenResolvableLabel_ClearsTheAbsenceReport`. + +**Positive: the suppression filters before row construction, not after.** `RetainedRows` is applied to the +presented list before `BuildRows`, and the same retained instance is handed to `AttachSegmentKeys`, with an +in-code comment stating why. Row ids are `row-` over that sequence, so filtering afterwards would have +misaligned every row after the suppressed one. The test deliberately places the suppressed row in the middle of a +four-row sequence and then activates a segment and toggles a leaf on `row-2`, which would fail on a misaligned +attachment. That is a correctly constructed regression pin rather than a shape assertion. + +**Positive: `RetainedRows` returns the original instance when nothing was suppressed**, so the common path +allocates nothing and the row identity handed to the builder is unchanged from before this change. + +**Positive: segment identity is preserved by reference through the trim.** +`ArchiveChainProjection.TryTrimBelowArchiveRoot` copies existing `FolderBreadcrumbSegment` references into the +output array rather than rebuilding them, and +`TryTrimBelowArchiveRoot_ChainPassesThroughRoot_ReturnsSegmentsAfterTheRoot` asserts `BeSameAs` on two of them. +That rules out a rebuild silently dropping a key or a display name. + +**Positive: AC3 is safe by construction and is still pinned explicitly.** The trim removes leading segments only, +so the leaf — and therefore the filing target substituted into it — is untouched. The specification required this +be its own assertion rather than left to inspection, and +`BindRowsAsync_TrimmedChain_PreservesFilingTargetAndScoreKey` asserts both halves: `SelectedFolderPath` equals the +archive-relative stem after a `rowSelected` round trip, and the percentage still renders. + +## Findings + +### CR-1 — Non-blocking, Low-Medium. The AC5 recents projection creates a narrow new throw site. + +**Files:** `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs:795` (`AddRecents`) and `:876` (`AddRecentRows`). + +**Observation.** Both members now read `var r = _globals.Ol.ArchiveRootPath;` unconditionally inside the +`RecentsList.Count > 0` branch. `ArchiveRootPath` throws `InvalidOperationException` when the archive root is +unresolvable — `TaskMaster/AppGlobals/AppOlObjects.cs:260-270`, documented on the property with an explicit +`` tag. Before this change neither member read that property. + +The reachable state is narrow but real. In `FolderPredictor.FolderArray` at `:221-228`, `AddSuggestions` is +guarded by `Suggestions.Count > 0` while `AddRecents` is guarded by `RecentsList.Count > 0`. `AddSuggestions` in +turn reaches `ArchiveRootPath` only through `ProjectSuggestionPath`, which is invoked per element of +`Suggestions.ToArray(5)`, so with zero suggestions the property is never touched on the old code path. The same +shape holds for `FolderRowArray` at `:249-256`. So in the state (zero suggestions) and (non-empty recents) and +(unresolvable archive root), `AddRecents` is now the first reader and the getter throws where it previously did +not. + +**Why this matters more than usual here.** The specification reasoned explicitly and at length about not creating +new throw sites for this exact property — that is the entire justification for the provider's accessor being a +lazy `Func` wrapped in a `try`. The same reasoning was not carried across to the two recents sites. + +**Mitigating facts, which are why this is not Blocking.** On the QuickFiler item-view path the surrounding method +already reads the same property a few lines later: +`QuickFiler/Controllers/QfcItemController.FolderHandling.cs:231-234` calls +`ProjectPredeterminedFolder(_predeterminedFolder, _globals is null ? null : (_globals.Ol?.ArchiveRootPath ?? string.Empty))` +unconditionally, and that line is pre-existing context in this diff. So on that surface the change moves an +existing throw a few lines earlier rather than introducing one. The `FolderPredictor.FindFolder` path at `:337-340` +calls `AddSuggestions` unconditionally, but again only reaches the property when suggestions exist, so that path +does gain the new reader. And an unresolvable archive root is a globally degraded application state rather than a +routine one. + +**What would close it.** Read the root once through a small private helper that returns `null` on +`InvalidOperationException` — the same treatment `OutlookFolderHierarchyProvider.TryReadArchiveRoot` already +applies — and pass that value to `ToDisplayStem`, which is already lenient about a null root. That is a +three-line change, keeps AC5's behaviour identical in every non-degraded state, and restores the invariant the +specification set for this property. Add one test seeding a predictor whose `ArchiveRootPath` getter throws and +asserting `FolderArray` still returns the recents unprojected. + +### CR-2 — Non-blocking, Low. The additive AC6 score alias can shadow a genuine relative-keyed score. + +**File:** `QuickFiler/Controllers/BreadcrumbBridgeRouter.cs`, `WithProjectedScoreKeys`. + +**Observation.** For each score whose path is archive-rooted, a second `FolderScore` carrying the projected stem +is appended immediately after the original. `BreadcrumbRowBuilder.BuildProbabilityIndex` assigns through its +indexer, so the last write for a key wins. If the score sequence ever contains both a rooted entry for a folder +and, later in the sequence, a genuine relative-keyed entry for the same folder with a different probability, the +ordering is safe; if the relative entry comes first and the rooted entry second, the rooted entry's alias +overwrites the relative entry's probability and the row renders the wrong percentage. + +**Assessment.** The scorer emits one entry per folder in practice, so a same-folder rooted/relative pair should +not occur. The additive form remains strictly better than substitution, which would have broken the rooted-presented +case outright — decision D7 is correct. This is an edge that the current design tolerates rather than a defect it +introduces. + +**What would close it.** Append the aliases as a second pass after all original scores rather than interleaving +them, so an original key can never be overwritten by an alias. Alternatively, add the alias only when no original +score already carries that key. Either is a small change inside the same method, and a test seeding a +relative-then-rooted pair for one folder would pin it. + +### CR-3 — Non-blocking, Low. The AC2 chain-misses-root diagnostic has no once-per gate. + +**File:** `UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs`, `GetAncestorChainAsync`, the +`EmitError($"Resolved ancestor chain does not pass through the configured archive root ...")` call. + +**Observation.** AC7 exists precisely because a per-render ERROR emission produced eighteen log lines for two +distinct labels in a single session. The new AC2 diagnostic is emitted on every failing render with no gate, so +it reproduces the same emission pattern for a chain that persistently misses the root — a mis-set archive root +would emit once per suggestion row per render. + +**Assessment.** AC2's text requires only that the condition be "logged as an error" and imposes no frequency +constraint, so this is compliant as written and is not a criterion failure. It is a consistency observation: the +change fixes one log-spam source and adds a second, smaller one adjacent to it. In normal operation every filing +target is under the archive root, so the path should be unreachable. + +**What would close it.** Reuse the existing `_reportedLabels` pattern with a second per-instance set keyed on the +leaf path or on the archive root, so the AC2 error is also once per distinct condition per provider instance. + +### CR-4 — Non-blocking, Low. Efc row suppression is applied to the breadcrumb document only. + +**Files:** `QuickFiler/Controllers/BreadcrumbBridgeRouter.cs` (`RetainedRows`) and +`QuickFiler/Controllers/EfcFormController.cs:1107-1108`. + +**Observation.** `EfcFormController` keeps a parallel presented-row surface: `_folderRows = rows ?? Array.Empty()` +followed by `BindFolderRows(_folderRows)`, set on the synchronous path, while the asynchronous +`BindBreadcrumbRowsAsync` at `:1112-1128` hands the same `rows` array to the router. The router's suppression +removes the zero-candidate label from the WebView2 document; it does not remove it from `_folderRows`. If that +list drives any other visible control, a label suppressed on one surface remains present on the other. + +**Assessment.** The suppressed label names a folder that no longer exists in the snapshot, so leaving it on a +secondary surface is a cosmetic inconsistency rather than a correctness problem, and the D-B decision explicitly +scopes suppression to where each surface composes its presented row set. The reviewer did not fully trace whether +`BindFolderRows` renders a user-visible list in the current Efc layout. + +**What would close it.** Either confirm in a comment that `_folderRows` is not a rendered surface, or route the +same retained list to both consumers. + +### CR-5 — Non-blocking, Informational. Comment overstates the number of construction sites. + +**File:** `UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs`, the `TryReadArchiveRoot` summary, +and the same phrase in `OutlookFolderHierarchyProviderTrimTests.cs`: "two of the three construction sites are +outside any try block". + +**Observation.** A repository-wide search for `new OutlookFolderHierarchyProvider(` returns exactly two production +call sites — `QfcItemController.BreadcrumbWiring.cs:22` and `EfcFormController.cs:1053` — with the remainder in +test files. The reasoning the comment supports is sound and the design decision is right; only the count is off. + +**What would close it.** Reword to "both production construction sites are outside any try block". + +### CR-6 — Non-blocking, Informational. Pre-existing `[ExcludeFromCodeCoverage]` carried across the relocation. + +**File:** `QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs:14`. + +**Observation.** `EnsureBreadcrumbPipeline` was moved verbatim out of `QfcItemController.ViewerSetup.cs`, +attribute included. The general unit-test policy's exclusion section is written against configuration `exclude` +entries matching production source paths, and this is a member-level attribute that predates the change, so no +new exclusion is introduced. Recording it so that the attribute's appearance in a newly created file is not later +mistaken for a new exclusion introduced by this item. + +**What would close it.** Nothing is required for this change. If the attribute is ever revisited, the member is a +thin wiring helper whose only untestable dependency is the concrete `ItemViewer` type check. + +### CR-7 — Non-blocking, Informational. Two committed follow-up promotions are not yet evidenced. + +**Source:** `spec.md` Rollout and Follow-up, "Follow-up issues to open" items (1) and (2), and decision D-A sites +5 and 6. + +**Observation.** The specification commits to promoting the two wrapper relative-path loaders as their own issue — +describing their unanchored, case-sensitive `Replace` with full-path fallback as "a real defect" that lets a +rooted label enter the persisted classifier corpus — and optionally the unanchored `ResolveFolderRoot` comparison +in the mail-item loading helper. No promotion receipt or follow-up record appears in the feature folder's +`evidence/` tree. + +**Assessment.** This is a delivery-hygiene item, not a code defect, and it does not affect any acceptance +criterion. It matters because prose in a feature folder does not survive the merge, whereas an issue does. + +**What would close it.** Run both through the promotion lifecycle and record the resulting issue numbers under +`evidence/issue-updates/`. + +## Best-practice checklist + +| Practice | Verdict | Note | +|---|---|---| +| Single responsibility per new type | PASS | One public member each, both pure. | +| Public API documented | PASS | Every new public member has an XML summary stating contract and every failing path, including the null, empty, whitespace-root, equal-to-root and leaf-is-root cases. | +| Comments explain why, not what | PASS | Consistently. The `WithProjectedScoreKeys` summary explains why addition rather than substitution; the `RetainedRows` summary explains why the filter precedes row construction; each `!` names its diagnostic. | +| Guard clauses over deep nesting | PASS | `TryTrimBelowArchiveRoot` uses `continue` and early `return false` rather than nested conditionals; `ToDisplayStem` is a two-branch guard plus a single condition. | +| No broad `catch (Exception)` without justification | PASS with one justified exception | `TryReadArchiveRoot` catches `Exception` deliberately; the rationale is in the summary and the alternative would create throw sites at call sites outside any try. The pre-existing broad catch in `FolderBreadcrumbBridgeRouter.SetSuggestionsAsync` is untouched. | +| Cancellation not swallowed | PASS | The router's suppression predicate explicitly excludes a null chain "arising from cancellation or from a provider fault", so a cancelled row is never treated as absent. | +| No magic values | PASS | Separator handling is delegated to `ArchiveStemContract`, which is unmodified; no new hard-coded `"\\"` prefix arithmetic remains at any converted site. | +| Thread safety where required | PASS | `ConcurrentDictionary` with `TryAdd` / `TryRemove` for both label sets, chosen because the resolve path is async and its continuations are not guaranteed to be on one thread. Documented in-code. | +| Tests assert behaviour, not implementation | PASS | Router tests assert on the rendered document text and on `SelectedFolderPath`; provider tests assert on returned chains and on emission counts through the injected sink. | +| No test asserts a superseded rule against a disabled configuration | PASS | The retargeted provider test is now constructed with a root accessor, which is how production constructs it. | +| File size ceiling | PASS | Largest added file 463 lines; no created or previously-compliant file exceeds 500 after formatting. | + +## Summary + +Accept. The implementation matches the specification's design, delivers all eight criteria, keeps its footprint +inside the authorised Write Set, and documents every deviation. The seven findings are improvements: CR-1 is the +one worth scheduling, because it slightly weakens an invariant the specification itself established; the rest are +consistency, edge-case and hygiene items. + +## Path hygiene + +No absolute host path, host account name, or machine name appears in this artifact. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t10-nullable.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t10-nullable.md new file mode 100644 index 000000000..4c64c53a3 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t10-nullable.md @@ -0,0 +1,30 @@ +# [P0-T10] Nullable-build baseline + +Timestamp: 2026-09-07T07-00 + +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true + +EXIT_CODE: 0 + +## MSBuild summary + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +Elapsed 00:00:16.28. Console output captured at default (normal) verbosity, 11624 lines. An independent scan of +the captured output found 0 lines carrying `: warning `, 0 lines carrying `: error `, and 0 lines carrying the +`CS86` nullable diagnostic prefix, which agrees with the summary counters. + +- WARNINGS: 0 +- ERRORS: 0 + +Output Summary: The nullable gate is green at the base commit. The command is character-for-character the +CLAUDE.md nullable command: `/p:Nullable=enable` was not added, and `/t:Build` was not substituted for +`/t:Rebuild`, so the gate could actually fail rather than exiting 0 with `CoreCompile` skipped. Nullable +enforcement in this repository is per-file opt-in through `#nullable enable`, and no opted-in file in the solution +produced a CS86xx diagnostic at this commit. MSBuild is not on this machine's PATH, so the Visual Studio 18 amd64 +MSBuild directory was prepended to `PATH` in the invoking shell; the command itself is unmodified. Host paths +reduced per R3. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t11-suites.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t11-suites.md new file mode 100644 index 000000000..dbabe9d8a --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t11-suites.md @@ -0,0 +1,55 @@ +# [P0-T11] UtilitiesCS.Test and QuickFiler.Test baseline runs + +Timestamp: 2026-09-07T07-05 + +Command: & $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\799-p0-t11-ut' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +(then) & $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\799-p0-t11-qft' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook' + +EXIT_CODE: 0 + +EXIT-CODE-UT: 0 +EXIT-CODE-QFT: 0 + +The single `EXIT_CODE:` field is the larger of the two invocation exit codes, both of which are 0. + +## Derived counters (read from TRX `ResultSummary/Counters`) + +BASELINE-UT-TOTAL: 4786 +BASELINE-UT-PASSED: 4786 +BASELINE-UT-FAILED: 0 +BASELINE-QFT-TOTAL: 1363 +BASELINE-QFT-PASSED: 1363 +BASELINE-QFT-FAILED: 0 + +Each `BASELINE-*-FAILED` value was read from its run's TRX `ResultSummary/Counters` `failed` attribute, not from +the console, because vstest prints no `Failed:` line at all on a fully passing run. + +## TRX documents read + +- UtilitiesCS.Test: `TestResults\799-p0-t11-ut\__2026-09-07_06_44_06_net481.trx` — the only TRX in + that results directory, so no most-recently-modified selection was needed. +- QuickFiler.Test: `TestResults\799-p0-t11-qft\__2026-09-07_06_46_42_net481.trx` — the only TRX in + that results directory (count verified as 1), so no most-recently-modified selection was needed. + +No TRX content is pasted into this artifact (R3); only parsed counter values are recorded. TRX filenames are +reduced per R3: the `runUser` and `computerName` segments in the generated file names are replaced with `` +and ``. + +## Excluded classes (R13) + +The UtilitiesCS.Test run excludes the four shell-icon classes that stall vstest on this machine, through +`FullyQualifiedName!~` clauses: + +1. HelperClasses.ShellUtilities_Tests +2. HelperClasses.ShellUtilitiesStatic_Tests +3. HelperClasses.SysImageListHelperTests +4. EmailIntelligence.OSBrowser_Tests + +Both runs additionally exclude `TestCategory=LiveOutlook`. The QuickFiler.Test run carries no shell-icon clause, +because those classes live in UtilitiesCS.Test. The reduced denominator recorded here is the same denominator the +Phase 2 and Phase 3 comparisons use. + +Output Summary: Both baseline suites are fully green at the base commit. UtilitiesCS.Test ran 4786 tests with 4786 +passed and 0 failed in 32.5 seconds; QuickFiler.Test ran 1363 tests with 1363 passed and 0 failed in 13.4 seconds. +Both invocations exited 0 and both printed `Test Run Successful.`. No pre-existing failure has to be carried into +the Phase 2 no-newly-failing comparison. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t12-coverage.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t12-coverage.md new file mode 100644 index 000000000..8884f352f --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t12-coverage.md @@ -0,0 +1,70 @@ +# [P0-T12] Nine-assembly coverage baseline + +Timestamp: 2026-09-07T07-12 + +Command: dotnet-coverage collect --output coverage\799-baseline.cobertura.xml --output-format cobertura --settings coverage\799-effective-coverage.config -- $vstest 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 '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx' '/ResultsDirectory:TestResults\799-p0-t12' '/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +(then the pinned D13 aggregation block over coverage\799-baseline.cobertura.xml) + +EXIT_CODE: 0 + +## Aggregation output line (verbatim, printed by the pinned block) + +``` +LINES_COVERED=112855 LINES_VALID=133485 BRANCHES_COVERED=26642 BRANCHES_VALID=33624 PACKAGES_MATCHED=9 +``` + +BASELINE-LINES-COVERED: 112855 +BASELINE-LINES-VALID: 133485 +BASELINE-BRANCHES-COVERED: 26642 +BASELINE-BRANCHES-VALID: 33624 +BASELINE-PACKAGES-MATCHED: 9 + +## Derived percentages + +BASELINE-LINE-PERCENT: 84.55 +BASELINE-BRANCH-PERCENT: 79.24 + +BASELINE_FLOOR: MET — measured against the D13 comparability index (84.55 percent lines) and not against the +repository line-coverage rate. The D13 aggregation counts every `line` element under a matched package, which +selects class-level and method-level elements alike and therefore over-counts the denominator relative to a +de-duplicated per-line count. The value is sound for the identical-method comparison [P3-T8] makes and is not a +policy measurement. No task in this plan gates on it, and a pre-existing repository floor result would not halt +the plan either way. + +## Test counters + +BASELINE-TOTAL-TESTS: 7048 +BASELINE-FAILED-TESTS: 0 + +Read from the TRX `ResultSummary/Counters` element: total 7048, passed 7048, failed 0, aborted 0. The run printed +`Test Run Successful.` and `Total tests: 7048 / Passed: 7048`. + +## Packages enumerated + +The Cobertura document contains 14 `package` elements. The nine first-party packages the aggregation matches are +QuickFiler, SVGControl, Tags, TaskMaster, TaskTree, TaskVisualization, ToDoModel, UtilitiesCS and VBFunctions, so +`PACKAGES_MATCHED` is 9 and no package-name mismatch occurred. The five packages present but not matched are +log4net, Mono.Reflection, Microsoft.IO.RecyclableMemoryStream, System.Linq.Async and System.Interactive, all +third-party. + +## TRX document read + +`TestResults\799-p0-t12\__2026-09-07_06_48_12_net481.trx` — the only TRX in that results directory +(count verified as 1), so no most-recently-modified selection was needed. Filename reduced per R3; no TRX content +is pasted. + +## Exclusions (R13, D14) + +The run excludes `TestCategory=LiveOutlook` and the four shell-icon classes HelperClasses.ShellUtilities_Tests, +HelperClasses.ShellUtilitiesStatic_Tests, HelperClasses.SysImageListHelperTests and +EmailIntelligence.OSBrowser_Tests. The nine test assemblies are named explicitly on the command line, so no +worktree copy under a `.claude` segment can be enumerated or loaded. Instrumentation used the derived settings +document `coverage\799-effective-coverage.config`, which is `coverage.config` plus one appended +`` entry excluding `*.Test.dll` modules from the measured denominator. + +Output Summary: The full nine-assembly suite ran green under `dotnet-coverage` (D12 form, not +`/EnableCodeCoverage`): 7048 tests, 7048 passed, 0 failed, exit code 0, 55.5 seconds. The pinned D13 aggregation +matched all nine first-party packages and produced 112855 covered of 133485 valid lines (84.55 percent) and 26642 +covered of 33624 valid branches (79.24 percent). These five counters are the baseline side of the [P3-T8] +comparison, which must apply this identical aggregation to the post-change document and must state `lines-valid` +comparability as an explicit precondition. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t13-measurability.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t13-measurability.md new file mode 100644 index 000000000..400750f67 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t13-measurability.md @@ -0,0 +1,52 @@ +# [P0-T13] Coverage measurability of the Write Set production files + +Timestamp: 2026-09-07T07-14 + +Command: separator-anchored `class` `filename` query over coverage\799-baseline.cobertura.xml +(the [P0-T13] command block, matching `$f.EndsWith('\' + $n) -or $f.EndsWith('/' + $n)`) + +EXIT_CODE: 0 + +## Class-element counts the determination was made from + +``` +OutlookFolderHierarchyProvider.cs classElements=6 +FolderPredictor.cs classElements=13 +QfcItemController.FolderHandling.cs classElements=6 +BreadcrumbBridgeRouter.cs classElements=6 +EfcFormController.cs classElements=43 +QfcItemController.ViewerSetup.cs classElements=9 +ArchiveStemContract.cs classElements=1 +``` + +## Determination + +MEASURABLE: UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs +MEASURABLE: UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs +MEASURABLE: QuickFiler/Controllers/QfcItemController.FolderHandling.cs +MEASURABLE: QuickFiler/Controllers/BreadcrumbBridgeRouter.cs +MEASURABLE: QuickFiler/Controllers/EfcFormController.cs +MEASURABLE: QuickFiler/Controllers/QfcItemController.ViewerSetup.cs +MEASURABLE: UtilitiesCS/OutlookObjects/Folder/ArchiveStemContract.cs + +Seven `MEASURABLE:`/`UNMEASURABLE:` lines are recorded: one for each of the six existing Write Set production +paths, plus ArchiveStemContract.cs. ArchiveStemContract.cs is not modified by this plan; its measurability is +recorded because [P2-T1], [P2-T2] and [P2-T9] all route through it, so a zero-class-element result there would +explain an otherwise puzzling [P3-T7] outcome. It reports one class element, so that explanation does not apply. + +## Files this plan creates (measured for the first time by [P3-T9]) + +NEW: UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs +NEW: UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs + +## Match anchoring + +The trailing-name match is anchored on a directory separator, so an unanchored suffix cannot over-select a sibling +whose name merely ends with the same characters. The concrete case this protects against in this Write Set is +UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs, a sibling-owned file whose name ends with the +characters of BreadcrumbBridgeRouter.cs but is preceded by `r` rather than by a separator, so it is correctly +excluded from the count of 6 recorded for QuickFiler/Controllers/BreadcrumbBridgeRouter.cs. + +Output Summary: All seven queried production files are measurable in the baseline Cobertura document, with class +element counts ranging from 1 to 43 and no zero result. No Write Set production file is invisible to the coverage +harness, so the [P3-T7] changed-line coverage determination has a source of data for every file it must inspect. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t14-sizes.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t14-sizes.md new file mode 100644 index 000000000..fc0def606 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t14-sizes.md @@ -0,0 +1,85 @@ +# [P0-T14] Baseline line counts of the files this plan edits or creates + +Timestamp: 2026-09-07T07-16 + +Command: (Get-Content -LiteralPath ).Count for each path below + +EXIT_CODE: 0 + +CEILING: 500 (applies to *.cs only) + +## Existing Write Set production paths (.cs) + +- UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs = 141 +- UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs = 1003 +- QuickFiler/Controllers/QfcItemController.FolderHandling.cs = 312 +- QuickFiler/Controllers/BreadcrumbBridgeRouter.cs = 304 +- QuickFiler/Controllers/EfcFormController.cs = 1320 +- QuickFiler/Controllers/QfcItemController.ViewerSetup.cs = 500 + +## Retargeted Write Set test paths (.cs) + +- UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs = 479 +- QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs = 354 + +## NO HUNK test paths from D8 (.cs) + +- QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue439Tests.cs = 455 +- QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue439Tests.Activation.cs = 253 + +## Files this plan creates + +Each of the eight paths below was probed with `Test-Path` and returned False, so none exists at baseline and none +has a measurable baseline line count. They are recorded as NOT PRESENT rather than as a count of zero, and their +first measurement is made by [P3-T10] after the final CSharpier pass. + +- NOT PRESENT AT BASELINE: UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs +- NOT PRESENT AT BASELINE: UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs +- NOT PRESENT AT BASELINE: QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs +- NOT PRESENT AT BASELINE: UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs +- NOT PRESENT AT BASELINE: UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs +- NOT PRESENT AT BASELINE: UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs +- NOT PRESENT AT BASELINE: UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs +- NOT PRESENT AT BASELINE: QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs + +## PROJECT-FILE (exempt) + +Project files are recorded as exempt observations rather than asserted against the ceiling, per R8: +.claude/rules/general-code-change.md caps production code, test code and reusable script files at 500 lines and +does not reach project files, and `.csharpierignore` lines 9-14 record that project files are owned by Visual +Studio and are not C# source. + +- PROJECT-FILE (exempt): UtilitiesCS/UtilitiesCS.csproj = 1315 +- PROJECT-FILE (exempt): QuickFiler/QuickFiler.csproj = 605 +- PROJECT-FILE (exempt): UtilitiesCS.Test/UtilitiesCS.Test.csproj = 976 +- PROJECT-FILE (exempt): QuickFiler.Test/QuickFiler.Test.csproj = 529 + +## PRE-EXISTING OVER CEILING + +Three files are already over the 500-line ceiling before any change in this plan. They are disclosed here, not +repaired, and are gated by the D11 per-file budgets rather than by the ceiling: + +- UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs = 1003 — in the Write Set. D11 budget: at or below 1003 + (no growth). +- QuickFiler/Controllers/EfcFormController.cs = 1320 — in the Write Set. D11 budget: at or below 1322, that is + baseline plus at most two lines, because the single added constructor argument is formatted by CSharpier as an + additional line. +- UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs = 1066 — NOT touched by this plan. + +## Ceiling-relevant statements required by the acceptance condition + +- `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` is EXACTLY 500 lines, at the ceiling and not merely + near it. This is the file R9's hard ordering constraint protects: [P2-T3] must relocate the breadcrumb pipeline + helper into the new partial before [P2-T14] adds the constructor argument, or the file passes through a 501-line + intermediate state. D11 budget: at or below 500 (hard). +- `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs` is 479 lines and therefore has + 21 lines of headroom against the 500-line ceiling. That headroom is why D9 places the no-accessor companion + case in the new file OutlookFolderHierarchyProviderTrimTests.cs rather than in this file, and why the Write Set + gives this file a budget of +4 lines (ceiling 483) for the [P1-T13] retarget. + +Output Summary: All ten existing `.cs` paths have numeric baseline counts, and every count reproduces the figure +the plan's citation table records. Three files are over the ceiling before any change and are disclosed above with +their D11 budgets; the four project files are recorded under the exempt heading with the R8 reason. The two +ceiling-relevant statements the acceptance condition requires are recorded: ViewerSetup.cs is exactly 500, and +OutlookFolderHierarchyProviderTests.cs has 21 lines of headroom. [P3-T10] re-measures these counts after the final +CSharpier pass, because the formatter can change line counts. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t15-tests.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t15-tests.md new file mode 100644 index 000000000..1c51ee191 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t15-tests.md @@ -0,0 +1,54 @@ +# [P0-T15] Pre-change status of the retargeted and must-stay-green tests + +Timestamp: 2026-09-07T07-20 + +Command: TRX `TestDefinitions/UnitTest` to `Results/UnitTestResult` join over the two TRX documents [P0-T11] +wrote, resolving each fully qualified name as `TestMethod/@className` + `.` + `TestMethod/@name` and reading +`UnitTestResult/@outcome` + +EXIT_CODE: 0 + +Source documents (R3-reduced filenames, no TRX content pasted): + +- `TestResults\799-p0-t11-ut\__2026-09-07_06_44_06_net481.trx` +- `TestResults\799-p0-t11-qft\__2026-09-07_06_46_42_net481.trx` + +## The two D9 retarget targets + +BASELINE-PASS: UtilitiesCS.Test.OutlookObjects.Folder.OutlookFolderHierarchyProviderTests.GetAncestorChainAsync_HappyPath_ReturnsRootToLeafSegments +BASELINE-PASS: QuickFiler.Controllers.Tests.QfcItemController_FolderHandlingTests.ProjectPredeterminedFolder_BoundaryCases_MatchFolderPredictorProjection + +## The D9 recents and projection tests that must stay green + +BASELINE-PASS: UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests.FolderArray_WhenSuggestionsAndRecentsExist_ReturnsSuggestionsThenRecents +BASELINE-PASS: UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests.AddRecents_WhenRecentsExist_AppendsHeaderAndEntries +BASELINE-PASS: UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests.Issue609_FolderPredictor_ProjectsOnlyInRootFullSuggestionPaths +BASELINE-PASS: UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests.Issue609_FolderPredictor_ProjectsCaseVariantInRootFullSuggestionPath +BASELINE-PASS: UtilitiesCS.Test.OutlookObjects.Folder.FolderRowTests.FolderRowArray_WithSuggestionsAndRecents_MatchesFolderArrayTextAndTagsKinds +BASELINE-PASS: UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests.GetOlSubpath_WhenAncestorEndsWithSlashOrChildrenExcluded_ReturnsExpectedSegment + +## The ten tests of the partial class BreadcrumbBridgeRouterIssue439Tests (D8 NO HUNK) + +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue439ArchiveRelativeRowsRenderLineagePreserveFilingTargetAndProbability +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue439RootedTargetUsesOriginalPathForProviderLookupCaseInsensitively +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue439UnresolvedChainsUseSelectableFallbackForEveryDiagnosableProviderOutcome +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue439InvalidTypedNavigationDoesNotSelectBannerOrPseudoRows +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue439ArchiveRootBoundarySelectionAndHostEventRemainDeterministic +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue439SlashOnlyArchiveRootPreservesFullHierarchySelection +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue609_DirectRowSelection_UsesFullLookupAndRelativeFilingTarget +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue609_AncestorActivation_EmitsArchiveRelativeFilingTarget +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue609_ImmediateChildActivation_EmitsArchiveRelativeFilingTarget +BASELINE-PASS: QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests.Issue439AncestorActivationQueriesAncestorKeyAndSelectsArchiveRelativeChild + +Count: 18 `BASELINE-PASS:` lines, one per named test, 0 `BASELINE-FAIL:` lines. Every one of the 18 named tests was +located in the TRX documents this plan wrote — 7 in the UtilitiesCS.Test document and 11 in the QuickFiler.Test +document — so no line is recorded from an assumption. All ten members of the `BreadcrumbBridgeRouterIssue439Tests` +partial class resolve to the single class name `QuickFiler.Test.Controllers.BreadcrumbBridgeRouterIssue439Tests`, +which spans the base file and the Activation partial. + +Output Summary: All 18 tests pass at the base commit. The set includes +Issue439UnresolvedChainsUseSelectableFallbackForEveryDiagnosableProviderOutcome, which pins today's null-chain +selectable-fallback rendering and is exactly the path [P2-T12] modifies, so the modified path is guarded on the +baseline side. Because every entry is `BASELINE-PASS:`, the Phase 2 no-newly-failing comparison reduces to a +requirement that all 18 still pass after the change, with the two D9 retarget targets excepted in the specific +respect that [P1-T13] and [P1-T14] rewrite their assertions. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t2-base.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t2-base.md new file mode 100644 index 000000000..1b24fcce2 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t2-base.md @@ -0,0 +1,17 @@ +# [P0-T2] Branch and base commit + +Timestamp: 2026-09-07T06-38 + +Command: git rev-parse --abbrev-ref HEAD; git rev-parse HEAD; git status --porcelain --untracked-files=all + +EXIT_CODE: 0 + +BASE-BRANCH: bug/breadcrumb-lineage-below-archive-root-799 +BASE-SHA: 2085504e6daaa11b9ec0a8857e7777cf9b10143f + +Output Summary: HEAD of the item worktree is the merge commit that brought origin/main into this branch +immediately before execution began, so every anchored diff later in this plan measures only this item's own +footprint. Porcelain status at the time of capture showed exactly two entries, both produced by [P0-T1] earlier in +this same phase: the modified plan file (its [P0-T1] checkbox) and the untracked +`/evidence/baseline/phase0-instructions-read.md` artifact. No source file is modified. Recorded per R6; +every later `git diff` in this plan binds `$BaseSha` from this artifact's `BASE-SHA` line. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t3-sdk.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t3-sdk.md new file mode 100644 index 000000000..3d766c25f --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t3-sdk.md @@ -0,0 +1,30 @@ +# [P0-T3] Repository-local .NET SDK bootstrap + +Timestamp: 2026-09-07T06-45 + +Command: pwsh -NoProfile -File scripts\vscode\Install-RepoDotNetSdk.ps1 ; then +$env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path ; $env:PATH = "$env:DOTNET_ROOT;$env:PATH" ; +dotnet --version ; Test-Path '.dotnet-sdk\sdk\8.0.205' + +EXIT_CODE: 0 + +EXEC-ENVIRONMENT: pwsh-permitted + +## Before / after existence + +- BEFORE: `.dotnet-sdk` exists = False +- BEFORE: `.dotnet-sdk\sdk\8.0.205` exists = False +- AFTER: `.dotnet-sdk` exists = True +- AFTER: `.dotnet-sdk\sdk\8.0.205` exists = True + +## Printed version + +`dotnet --version` printed `8.0.205` with exit code 0. + +Output Summary: The worktree had no repository-local SDK tree before this task, consistent with the Phase 0 +preamble. The install script downloaded and extracted SDK 8.0.205 into `\.dotnet-sdk` and reported +`Installed repo-local .NET SDK 8.0.205`. After the install, the `global.json`-pinned marker directory +`.dotnet-sdk\sdk\8.0.205` exists and `dotnet --version` prints `8.0.205`, a version beginning `8.0.`, so the pin is +satisfied. The derived line `EXEC-ENVIRONMENT: pwsh-permitted` is recorded per plan rule R11b: this executor +session is not worktree-isolated and PowerShell command blocks ran normally, including this one, so no task in +this plan is blocked by the R11b constraint. Host paths in this artifact are reduced per R3. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t4-restore.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t4-restore.md new file mode 100644 index 000000000..04e87d6fe --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t4-restore.md @@ -0,0 +1,54 @@ +# [P0-T4] NuGet package restore and analyzer HintPath resolution + +Timestamp: 2026-09-07T06-48 + +Command: msbuild TaskMaster.sln /t:Restore /m /p:RestorePackagesConfig=true /p:Configuration=Debug "/p:Platform=Any CPU" +(then the four-project Analyzer Include probe from the [P0-T4] command block) + +EXIT_CODE: 0 + +## Packages subdirectory count + +packages-subdirs before=0 after=172 + +## MSBuild restore summary + +``` +Installed: + 172 package(s) to packages.config projects +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +## Analyzer Include HintPath resolution + +40 `` items are declared across the four Write Set project files +(UtilitiesCS 9, QuickFiler 9, UtilitiesCS.Test 11, QuickFiler.Test 11). Every one resolved: + +RESOLVED: 40 +UNRESOLVED: 0 + +Distinct resolved analyzer paths, one per package (each appears in two, three or four of the project files): + +- RESOLVED: ..\packages\Meziantou.Analyzer.3.0.203\analyzers\dotnet\roslyn5.0\cs\Meziantou.Analyzer.dll +- RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator.CSharp.Analyzers.dll +- RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator_Analyzers_Roslynator.Common.dll +- RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator_Analyzers_Roslynator.Core.dll +- RESOLVED: ..\packages\Roslynator.Analyzers.5.0.0\analyzers\dotnet\roslyn4.7\cs\Roslynator_Analyzers_Roslynator.CSharp.dll +- RESOLVED: ..\packages\AsyncFixer.2.1.0\analyzers\dotnet\cs\AsyncFixer.dll +- RESOLVED: ..\packages\Microsoft.CodeAnalysis.BannedApiAnalyzers.5.6.0\analyzers\dotnet\cs\Microsoft.CodeAnalysis.BannedApiAnalyzers.dll +- RESOLVED: ..\packages\Microsoft.CodeAnalysis.BannedApiAnalyzers.5.6.0\analyzers\dotnet\cs\Microsoft.CodeAnalysis.CSharp.BannedApiAnalyzers.dll +- RESOLVED: ..\packages\SonarAnalyzer.CSharp.10.33.0.1635\analyzers\SonarAnalyzer.CSharp.dll +- RESOLVED: ..\packages\MSTest.Analyzers.4.4.0\analyzers\dotnet\cs\MSTest.Analyzers.dll (test projects only) +- RESOLVED: ..\packages\MSTest.Analyzers.4.4.0\analyzers\dotnet\cs\MSTest.Analyzers.CodeFixes.dll (test projects only) + +Output Summary: The `packages` tree was absent before this task (0 subdirectories), which is the bootstrap +condition the Phase 0 preamble describes rather than a repair. The restore installed 172 packages and MSBuild +reported `Build succeeded` with 0 warnings and 0 errors, exit code 0. The analyzer probe found 40 declared +`` items across the four Write Set project files and resolved all 40 against the restored +packages tree, with zero `UNRESOLVED:` lines, so the pre-planning analyzer version-parity measurement +(Meziantou.Analyzer 3.0.203 and Roslynator.Analyzers 5.0.0) holds in this worktree and no back-fill is required. +CS0006 from an unresolved analyzer path is therefore excluded as a cause of any [P0-T9] or [P0-T10] result. +MSBuild is not on this machine's PATH; the Visual Studio 18 amd64 MSBuild directory was prepended to `PATH` in the +invoking shell so that the command executes in exactly the form the plan states. Host paths reduced per R3. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t5-tools.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t5-tools.md new file mode 100644 index 000000000..e353b3205 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t5-tools.md @@ -0,0 +1,27 @@ +# [P0-T5] Manifest-pinned dotnet tool restore + +Timestamp: 2026-09-07T06-50 + +Command: $env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path ; $env:PATH = "$env:DOTNET_ROOT;$env:PATH" ; +dotnet tool restore ; dotnet tool run csharpier --version + +EXIT_CODE: 0 + +## Printed output + +``` +Tool 'csharpier' (version '1.2.6') was restored. Available commands: csharpier + +Restore was successful. +``` + +`dotnet tool run csharpier --version` printed: + +``` +1.2.6 +``` + +Output Summary: `dotnet tool restore` exited 0 and restored the manifest-pinned CSharpier. The version invocation +also exited 0 and printed `1.2.6`, which contains the required substring 1.2.6, so the formatter used by every +later CSharpier task in this plan is the manifest-pinned version rather than a global install. Both commands ran +with `DOTNET_ROOT` and `PATH` re-bound to the repository-local SDK per R11. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t6-dotnet-coverage.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t6-dotnet-coverage.md new file mode 100644 index 000000000..b80f07a5d --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t6-dotnet-coverage.md @@ -0,0 +1,28 @@ +# [P0-T6] dotnet-coverage resolution + +Timestamp: 2026-09-07T06-51 + +Command: $probe = Get-Command dotnet-coverage -ErrorAction SilentlyContinue ; (probe branch taken) ; +dotnet-coverage --version + +EXIT_CODE: 0 + +BRANCH TAKEN: probe branch — `Get-Command dotnet-coverage` returned a command, so the +`dotnet tool install --global dotnet-coverage` branch and its PATH prepend were NOT executed. + +DOTNET-COVERAGE-ON-PATH: true + +## Printed version + +``` +18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342 +``` + +The resolved command is `dotnet-coverage.exe`. + +Output Summary: The probe branch is the branch the task predicted for this host. `dotnet-coverage` was already +resolvable with no PATH amendment beyond the repository-local SDK prepend required by R11, and +`dotnet-coverage --version` exited 0 printing version 18.10.0. The tool is available for the D12 coverage form +used by [P0-T12] and by the Phase 3 coverage tasks. The probe was performed with `Get-Command` rather than by +running the tool, because an unresolvable command name raises a PowerShell CommandNotFoundException instead of +setting a non-zero exit code. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t7-vstest.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t7-vstest.md new file mode 100644 index 000000000..6f2551f6e --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t7-vstest.md @@ -0,0 +1,27 @@ +# [P0-T7] vstest.console.exe resolution + +Timestamp: 2026-09-07T06-53 + +Command: $vswhere = "${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 ; +$vstest ; Test-Path $vstest + +EXIT_CODE: 0 + +VSTEST-PATH: C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe + +## Verification + +- `Test-Path` on the resolved path returned `True`, so `VSTEST-PATH` names an existing file. +- vswhere returned exactly one match for the `-find` pattern, so `Select-Object -First 1` did not discard an + alternative installation. +- vswhere exit code 0. When the exit code is read directly after the `| Select-Object -First 1` pipeline it comes + back empty, because `Select-Object -First` stops the upstream pipeline before the native command's exit code is + published. The value above was therefore read from an equivalent invocation that assigns the full vswhere output + first and applies `Select-Object -First 1` afterwards; that invocation resolved the identical single path. + +Output Summary: vstest.console.exe resolves to the Visual Studio 18 Community Test Platform. This is the one +artifact in the plan exempted from R3 path reduction, because pinning the full resolved path is the task's whole +purpose; reduced per R3, the path is +`\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe`. +Every later task that binds `$vstest` re-runs the two resolution lines above in its own shell, per R11. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t8-csharpier.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t8-csharpier.md new file mode 100644 index 000000000..adf845c7c --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t8-csharpier.md @@ -0,0 +1,22 @@ +# [P0-T8] CSharpier formatting baseline + +Timestamp: 2026-09-07T06-55 + +Command: dotnet tool run csharpier check . + +EXIT_CODE: 0 + +## Verbatim printed line + +``` +Checked 1593 files in 6423ms. +``` + +BASELINE-CSHARPIER-CHECKED-FILES: 1593 + +Output Summary: The read-only CSharpier check exited 0 and printed the single success-case line +`Checked 1593 files in 6423ms.` with no drift entry, so the tree is formatting-clean at the base commit and there +is no disclosed pre-existing drifting-path set to carry forward. `check` returns non-zero on drift, so the exit +code is the gate for this task. The formatter was invoked through `dotnet tool run` against the manifest-pinned +version 1.2.6 confirmed by [P0-T5]. The checked-file count includes the `*.xml` and `packages.config` documents +CSharpier 1.2.6 processes in addition to `*.cs`; project files are excluded by `.csharpierignore`. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t9-analyzers.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t9-analyzers.md new file mode 100644 index 000000000..56219b0cf --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/p0-t9-analyzers.md @@ -0,0 +1,31 @@ +# [P0-T9] Analyzer-build baseline + +Timestamp: 2026-09-07T06-58 + +Command: msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true + +EXIT_CODE: 0 + +## MSBuild summary + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +Elapsed 00:00:19.20. Console output captured at default (normal) verbosity, 5247 lines. An independent scan of +the captured output for the literal `: warning ` and `: error ` diagnostic markers found 0 lines of each, which +agrees with the summary counters. + +- WARNINGS: 0 +- ERRORS: 0 + +Output Summary: The analyzer gate is green at the base commit. This is the CLAUDE.md analyzer command exactly, +with `/t:Rebuild` rather than `/t:Build`, so `CoreCompile` ran on every project and the analyzers actually +executed rather than being skipped by MSBuild incrementality. [P0-T4] completed before this task and restored 172 +packages with all 40 `` HintPaths resolved, so the EnsureNuGetPackageBuildImports Error target +that fires at BeforeTargets PrepareForBuild in each of the four Write Set projects could not have fired here: this +result is an analyzer measurement, not a bootstrap outcome, and a bootstrap failure has not been misrecorded as a +red analyzer gate. MSBuild is not on this machine's PATH, so the Visual Studio 18 amd64 MSBuild directory was +prepended to `PATH` in the invoking shell; the command itself is unmodified. Host paths reduced per R3. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/phase0-instructions-read.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/phase0-instructions-read.md new file mode 100644 index 000000000..83d92c8a2 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/baseline/phase0-instructions-read.md @@ -0,0 +1,26 @@ +# [P0-T1] Phase 0 policy read record + +Timestamp: 2026-09-07T06-37 + +Policy Order: policy-compliance-order sequence — (1) CLAUDE.md, (2) .claude/rules/general-code-change.md, +(3) .claude/rules/general-unit-test.md, (4) language-specific rules for the files in scope (C#): +.claude/rules/csharp.md, (5) .claude/rules/tonality.md. + +Command: Read tool applied to each of the five paths below, rooted at the item worktree; line counts measured with +`pwsh -NoProfile -Command "(Get-Content -LiteralPath ).Count"`. + +EXIT_CODE: 0 + +## Files read (in order) + +1. CLAUDE.md — 447 lines +2. .claude/rules/general-code-change.md — 80 lines +3. .claude/rules/general-unit-test.md — 105 lines +4. .claude/rules/csharp.md — 96 lines +5. .claude/rules/tonality.md — 80 lines + +Output Summary: All five policy files exist in the item worktree and were read in full in the +policy-compliance-order sequence. Line counts: 447, 80, 105, 96, 80. Constraints carried into execution: +CSharpier via `dotnet tool run` only; the two MSBuild gate commands use `/t:Rebuild` and must not add +`/p:Nullable=enable`; MSTest + Moq + FluentAssertions for tests; 500-line file ceiling for production, test and +reusable script files; no temporary files in tests; professional and non-hyperbolic tone in all artifacts. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/issue-updates/issue-799.2026-09-07T08-35.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/issue-updates/issue-799.2026-09-07T08-35.md new file mode 100644 index 000000000..b525da836 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/issue-updates/issue-799.2026-09-07T08-35.md @@ -0,0 +1,94 @@ +# Issue #799 outcome update — local mirror + +Timestamp: 2026-09-07T08-35 + +PostedAs: unknown + +POSTING BLOCKED — this delegation is scoped to the item worktree and explicitly excludes pushing, opening a pull +request and merging; GitHub interaction for this item is handled after review. The text below was written to the +local feature `issue.md` under a new `## Outcome` heading appended after the existing `## Next Step` section. No +existing heading was altered, per that file's automation note. The same text appears in both places. + +Issue URL: https://github.com/drmoisan/TaskMaster/issues/799 + +Command: local file edit only; no `gh` invocation was made. + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +## Exact text written to `issue.md` + +## Outcome + +Implemented on 2026-09-07 on branch `bug/breadcrumb-lineage-below-archive-root-799`, across the three phases of +`plan.2026-09-06T22-01.md`. + +**This is a specification change superseding issue #439, not a regression fix against it.** Issue #439 delivered +full root-to-leaf ancestor lineage deliberately, and that behaviour was correct against its own acceptance +criteria. This item narrows the rendered lineage to begin at the first segment below the archive root because the +mailbox and Archive segments carry no information in a system where every filing target is under the archive root, +and because they consume most of the row width and defeat the row-distinguishability goal #439 itself set out to +serve. Nothing in #439 is being repaired. + +**#439's filing-target and score-key constraint is preserved and carried forward as AC3.** The trim removes only +LEADING segments from the rendered chain; the filing value and the score-lookup key remain the archive-relative +stem, substituted into the LEAF segment, exactly as #439 required. AC3 pins this explicitly rather than leaving it +as an incidental consequence, via the test +`BindRowsAsync_TrimmedChain_PreservesFilingTargetAndScoreKey`. All ten tests of the #439 partial class +`BreadcrumbBridgeRouterIssue439Tests`, across both its files, pass unmodified: neither file carries a hunk in this +change, because every test in them drives a strict provider mock that sits below the trim boundary. + +### What was delivered + +- AC1 and AC2: the ancestor-chain trim lives in `OutlookFolderHierarchyProvider.GetAncestorChainAsync`, the single + seam both the QuickFiler drop-down and the Efc list route through, so one change serves both surfaces. A chain + that does not pass through the archive root, or whose leaf IS the root, is logged once and returns an empty + segment list, which routes each surface into its existing fallback. +- AC3: filing target and score-lookup key remain the archive-relative stem. +- AC4: `ProjectSuggestionPath` and `ProjectPredeterminedFolder` now both delegate to the new shared + `ArchiveStemProjection.ToDisplayStem`, built on `ArchiveStemContract.TryMakeArchiveRelative`. The empty-root + one-separator strip is eliminated. Four of the seven candidate sites were converted; three were deliberately + left, with reasons recorded in the specification's decision D-A. +- AC5: recent-folder entries are projected at both sites, the string append and the row-model mirror, preserving + the documented text-parity contract between the two lists. +- AC6: the Efc router adds a projected score alongside each raw score, so an archive-rooted suggestion presented as + a stem retains its percentage and a rooted-presented row does not lose its own. +- AC7: stale labels are logged once per label per provider instance rather than once per render, on both surfaces. + Zero-candidate labels are additionally suppressed from the rendered row set on the Efc surface. +- AC8: verified as a finding, not a fix. No renderer alters a leading underscore; the reported space was a + transcription artifact and a renderer change would have been a defect. + +### Deviations from the specification's own prose + +Four, each recorded by name with its reason in `spec.md` under Rollout & Follow-up, section Outcome: AC7 row +suppression is delivered on the Efc surface only; the AC6 score projection is additive rather than substitutive; +the two #439 Efc router test files carry no hunk; and the AC7 absence classification is published through a new +small public interface rather than through a fourth member on the shared hierarchy contract. + +### Verification + +The final toolchain loop closed clean in a single pass: CSharpier format and check both exit 0 over 1601 files, +the analyzer gate and the nullable gate each exit 0 with 0 Warning(s) and 0 Error(s), and the coverage-enabled +nine-assembly run exits 0 with 7085 tests, 7085 passed, 0 failed and `NEWLY-FAILING: NONE` against a 7048-test +baseline. First-party line coverage moved from 84.55 to 84.58 percent and branch coverage from 79.24 to 79.28 +percent on the pinned comparability index; no changed line lost coverage. Both new production types reach 100 +percent line and branch coverage. Evidence is under this feature folder's `evidence/qa-gates/` and +`evidence/regression-testing/` directories. + +### Acceptance criteria + +The authoritative acceptance-criteria source for this `full-bug` item is `spec.md`, section Acceptance Criteria. +All eight criteria AC1 through AC8 are checked off there. The mirrored list under "Proposed Fix / Validation +Ideas" above is left as captured, because it is the intake record rather than the tracked criteria source. + +## End of mirrored text + +Output Summary: The issue outcome was written to the local feature `issue.md` as a new `## Outcome` section and +mirrored here verbatim. It states that this item is a specification change superseding issue #439 rather than a +regression fix against it, and that #439's filing-target and score-key constraint is preserved and carried forward +as AC3. It was not posted to GitHub, because this delegation excludes remote interaction. + +## Path hygiene (R3) + +No absolute host path, host account name, or machine name appears in this artifact. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/other/preflight-r1-delta.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/other/preflight-r1-delta.md new file mode 100644 index 000000000..dd57b4bf4 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/other/preflight-r1-delta.md @@ -0,0 +1,99 @@ +# Preflight round 1 delta, adjudicated by the orchestrator + +Timestamp: 2026-09-06T23-40 + +Reviewer signal: `PREFLIGHT: REVISIONS REQUIRED` with `CONVERGENCE: FURTHER ROUNDS LIKELY`. + +## How to use this document + +Apply each ACCEPTED item using the reviewer's verbatim replacement text from the preflight report, +reproduced in the delegation prompt. For every item, report one disposition: +`applied-verbatim`, `applied-with-mechanical-reassembly`, or `not-applied-with-reason`. +Do not silently substitute your own wording. If you judge an accepted item wrong, leave it unapplied +and report the disagreement for the orchestrator to adjudicate. + +## Orchestrator adjudication summary + +The reviewer stated plainly that it could not execute a single command: the PreToolUse guard refuses +every `pwsh` invocation inside this isolated agent worktree. Its claims about runtime behaviour are +therefore reasoning rather than observation. The orchestrator adjudicated each such claim against the +committed evidence of the completed issue #791 run, which contains real recorded invocations on this +same host. + +### ACCEPTED, and confirmed by recorded observation + +- B5 and B6, and M4. On a fully passing run `vstest.console.exe` prints + `Test Run Successful. Total tests: 1339, Passed: 1339, Total time: 13.2586 Seconds.` and prints no + `Failed:` line at all. Source: the #791 baseline artifact `p0-t10-quickfiler-tests.md` lines 14-22, + which itself derives its failed count from the TRX `ResultSummary/Counters` element rather than + from the console. Any acceptance condition in this plan that reads `Failed: 0` from console output + is unsatisfiable on a green run. Apply the reviewer's replacements in full, including the + `EXIT-CODE-UT:` and `EXIT-CODE-QFT:` split with a single roll-up `EXIT_CODE:` field. + +### ACCEPTED on reading, verified independently by the orchestrator + +- B2 and B3. Nullable annotations. The three named files do open with `#nullable enable`, and the + nullable gate runs with warnings as errors, so the declared seams must carry `?`. +- B4. Author the new QuickFiler.Test file in C# 7.3-compatible syntax. The orchestrator verified that + `QuickFiler.Test/QuickFiler.Test.csproj` declares no `LangVersion` while + `UtilitiesCS.Test/UtilitiesCS.Test.csproj` declares `Latest` at its line 18, that no `.cs` file in + QuickFiler.Test carries a `#nullable enable` directive, and that 25 files in UtilitiesCS.Test do. + The two apparent modern constructs in QuickFiler.Test are inside comments and compile nothing. + Whether or not the compiler default is literally 7.3, authoring the new file in the conservative + syntax costs nothing and removes a real build risk, so this is applied as a precaution. +- B7. The double-quoted PowerShell pattern does not parse, and zero matches is this task's success + outcome, so the artifact needs `ExpectedExitCode: 1`. The reviewer observed the exit code directly + through an allowed git invocation. +- B8. The command block assigns four variables and never derives or prints the two values its + acceptance reads. +- B11. The AC7 row-suppression branch is delivered by a task but executed by no test, so it would + ship at zero hits and be checked off on the strength of provider-level tests that cannot reach it. + This is the most consequential finding in the report and it is an acceptance-criterion delivery + gap, not a style issue. +- B12. The 21-line headroom is not achievable, and the later ceiling gate then has no remedy. +- M1, M2, M3, M5. Counting and census errors, each checkable by reading. +- M6, M7, M8, m1, m2, m3, m4. Apply as written. + +### REJECTED, refuted by recorded observation + +- B9. The claim that full-framework MSBuild is not on PATH, and the consequent rewrite of eight + tasks to resolve and invoke a resolved MSBuild path, is refuted. The #791 baseline artifact + `p0-t3-nuget-restore.md` records the command + `msbuild TaskMaster.sln /t:Restore /m /p:RestorePackagesConfig=true /p:Configuration=Debug "/p:Platform=Any CPU"` + with `EXIT_CODE: 0` on this host, invoked as a bare command name, and the #791 plan's later gate + builds use the same bare form and are recorded complete. Do NOT apply B9. Keep the bare `msbuild` + invocations. The reviewer could not test PATH and asserted an unverifiable negative. + +### NARROWED + +- B1. The refusal of `pwsh` is a property of the isolated agent-worktree sandbox the reviewer ran in, + not a property of this plan. Execution of this plan happens later, in the execution phase, and the + identical command shapes are recorded as successfully executed in the #791 run. Do NOT add the + proposed POSIX download-and-extract substitute for the repository SDK installer: it hard-codes a + download URL, duplicates a maintained script, and widens scope. Apply only this narrowed clause, + which records the requirement and forbids silent substitution: + + ```markdown + **R11b — Execution-environment clause.** Every command block in this plan is a PowerShell block and + requires a session in which `pwsh` may be invoked. A worktree-isolated agent session refuses every + Bash invocation of `pwsh`, in both the `-Command` and the `-File` form. The executor records, in + `/evidence/baseline/p0-t3-sdk.md`, the derived line `EXEC-ENVIRONMENT: pwsh-permitted` once + it has confirmed a PowerShell block runs. An executor that cannot obtain such a session reports + BLOCKED at that task and stops; it must not substitute an unrecorded command shape for a documented + one, because a substituted shape is unreviewed and its success-case output is unobserved. + ``` + +- B10. The claim that the global-tool directory must be prepended to PATH is unproven here: the #791 + artifact `p0-t5-dotnet-coverage.md` records `dotnet-coverage --version` exiting 0 with the + probe-only branch taken, so the tool resolves on this host without any PATH amendment. Apply only + the `Get-Command` probe correction, which is sound on its own terms because an unresolvable command + name raises a terminating error rather than setting an exit code, so the existing branch condition + reads a value that does not exist. Do not assert in the plan that the PATH amendment is required; + state it as a conditional applied only when the install branch is taken. + +## Reviewer determinations the orchestrator confirms and does not reopen + +D5, D7 and D8 were each checked by the reviewer against the tree and confirmed. The AC7 zero-candidate +restriction, the AC8 verification-only disposition, the relocation ordering that keeps the 500-line +file within its ceiling, and the absence of any unsatisfiable no-growth gate were all verified. The +ordering-integrity sweep across all 74 tasks found no violation and no task body swap is required. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/other/preflight-r2-delta.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/other/preflight-r2-delta.md new file mode 100644 index 000000000..11b376541 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/other/preflight-r2-delta.md @@ -0,0 +1,50 @@ +# Preflight round 2 delta, adjudicated by the orchestrator + +Timestamp: 2026-09-07T00-20 + +Reviewer signal: `PREFLIGHT: REVISIONS REQUIRED` with `CONVERGENCE: FURTHER ROUNDS LIKELY`. +Four blocking findings, all local corrections to acceptance clauses and one task body. No phase, task +ordering, Write Set entry or design decision changes. + +## Adjudication + +All four blocking findings are ACCEPTED. Unlike round 1, none is rejected and none is narrowed. The +reviewer's round-1 runtime claims needed adjudication because it could not execute anything; these +four are all decidable by reading, and the orchestrator verified the two most consequential premises +directly against the tree. + +- B13 verified. `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` contains + `EnsureBreadcrumbPipeline();` at line 112 and `internal void EnsureBreadcrumbPipeline()` at line + 138. The relocation task moves lines 132 to 163 only, so the call at line 112 survives and an + acceptance demanding zero matches for the bare identifier can never be satisfied. +- B14 verified. `UtilitiesCS/OutlookObjects/Folder/FolderRow.cs` opens with `#nullable enable` at + line 1 and declares `public FolderRow(string text, FolderRowKind kind, FolderScore? score)` at line + 42, a non-nullable first parameter. Passing the newly nullable projection result there is CS8604, + which the nullable gate promotes to an error. +- B15 accepted. The third acceptance conjunct of the assets task cannot fail: the plan's own scope + rule restricts the enumeration it reads to a source pathspec, and the resources directory contains + no file matching that pathspec, so the conjunct is true for every possible execution. +- B16 accepted, and it is a defect the orchestrator introduced. The clause was appended in round 1 as + part of the accepted B2 replacement text. It asserts an observation that no scheduled command in + that phase produces, because the only build in that phase deliberately runs without the gate + switches. The correction moves the proof onto two diagnostic counts recorded by that build and + re-proved under enforcement in the final phase. + +## Non-blocking items + +m5 and m6 are accepted as written. m8 requires no change: it reports a count in the forwarded prose, +not in the plan. + +m7 is accepted with a different remedy than the reviewer proposed. Rather than rewording the trailing +signal line, the plan file should carry no line matching the preflight signal vocabulary at all. +Clearance is the executor's return recorded in the orchestrator checkpoint; a signal-shaped line +inside the plan file is a second, unmaintained assertion of the same fact and will be stale the moment +clearance is granted. + +## Round count + +This is the third preflight round against a two-round target. The overrun is legitimate rather than a +review failure: each round returned a complete enumeration rather than one defect at a time, and the +round-2 findings are in regions that only came into existence when the round-1 delta was applied. No +prior pass could have observed them. B16 in particular is a defect created by the round-1 delta +itself. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p2-t18-sizes-interim.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p2-t18-sizes-interim.md new file mode 100644 index 000000000..1d1abea6b --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p2-t18-sizes-interim.md @@ -0,0 +1,84 @@ +# [P2-T18] Interim (pre-format) line counts of every file this plan has edited or created + +Timestamp: 2026-09-07T07-40 + +Command: `(Get-Content -LiteralPath ).Count` for each path below + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +CEILING: 500 (applies to *.cs only) + +These counts are taken BEFORE the [P3-T1] CSharpier pass. The formatter can change line counts, so +[P3-T10] re-measures the same set afterwards and is the gating measurement. + +## Production `.cs` + +| Path | [P0-T14] baseline | Now | D11 budget | Verdict | +|---|---|---|---|---| +| `UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs` | NOT PRESENT | 63 | 500 | met | +| `UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs` | NOT PRESENT | 92 | 500 | met | +| `UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs` | 141 | 300 | 500 | met | +| `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` | 1003 | 1002 | 1003 | met | +| `QuickFiler/Controllers/QfcItemController.FolderHandling.cs` | 312 | 295 | 500, and [P2-T10] additionally requires at or below its 312 baseline | met | +| `QuickFiler/Controllers/BreadcrumbBridgeRouter.cs` | 304 | 407 | 500 | met | +| `QuickFiler/Controllers/EfcFormController.cs` | 1320 | 1321 | 1322 | met | +| `QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs` | NOT PRESENT | 41 | 500 | met | +| `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` | 500 | 467 | 500 (hard) | met | + +## Test `.cs` + +| Path | [P0-T14] baseline | Now | Budget | Verdict | +|---|---|---|---|---| +| `UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs` | NOT PRESENT | 176 | 500 | met | +| `UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs` | NOT PRESENT | 217 | 500 | met | +| `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs` | NOT PRESENT | 344 | 500 | met | +| `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs` | NOT PRESENT | 213 | 500 | met | +| `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs` | NOT PRESENT | 425 | 500 | met | +| `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs` | 479 | 480 | 483 ([P1-T13] +4) | met | +| `QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs` | 354 | 363 | 500 | met | + +## PROJECT-FILE (exempt) + +Recorded as exempt observations rather than asserted against the ceiling, per R8: the 500-line cap +in .claude/rules/general-code-change.md covers production code, test code and reusable script files +and does not reach project files, and .csharpierignore lines 9-14 record that project files are +owned by Visual Studio and are not C# source. + +- PROJECT-FILE (exempt): `UtilitiesCS/UtilitiesCS.csproj` = 1317 (baseline 1315, +2 Compile Include) +- PROJECT-FILE (exempt): `QuickFiler/QuickFiler.csproj` = 606 (baseline 605, +1 Compile Include) +- PROJECT-FILE (exempt): `UtilitiesCS.Test/UtilitiesCS.Test.csproj` = 980 (baseline 976, +4 Compile Include) +- PROJECT-FILE (exempt): `QuickFiler.Test/QuickFiler.Test.csproj` = 530 (baseline 529, +1 Compile Include) + +## Files within ten lines of their budget (named explicitly, with remaining headroom) + +- `QuickFiler/Controllers/EfcFormController.cs` — 1321 against a budget of 1322. Remaining headroom: + 1 line. The single added lazy root-accessor argument cost exactly one line, which is inside the + at-most-two-line allowance D11 derives for it. +- `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` — 1002 against a no-growth budget of 1003. + Remaining headroom: 1 line. The net figure is the sum of the D11-derived collapses (the + `ProjectSuggestionPath` body and the include-children branch of `GetOlSubpath`) against the two + recents projections and one added using directive. + +No other `.cs` file in this set is within ten lines of its budget. The next closest are +`UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs` at 480 against 483 +(3 lines of headroom, so it IS within ten and is named here for completeness) and +`QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` at 467 against 500 (33 lines). + +Corrected enumeration of the within-ten set, so the list above is not read as exhaustive: +`EfcFormController.cs` (1), `FolderPredictor.cs` (1) and +`UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs` (3). + +## Output Summary + +Every listed `.cs` count satisfies its D11 budget. The four project-file counts are recorded under +the exempt heading and are not asserted against the ceiling. Three `.cs` files are within ten lines +of their budget and are named above with their remaining headroom. The R9 ordering constraint held: +`QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` never passed through 501 lines, because +[P2-T3] removed 33 lines from it before [P2-T14] added the constructor argument to the relocated +member in the new partial. + +## Path hygiene (R3) + +No absolute host path, host account name, or machine name appears in this artifact. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t1-format.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t1-format.md new file mode 100644 index 000000000..ecb5dc025 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t1-format.md @@ -0,0 +1,121 @@ +# [P3-T1] CSharpier format pass + +Timestamp: 2026-09-07T07-49 + +Command: dotnet tool run csharpier format . +(preceded in the same block by `git add --intent-to-add -- '*.cs' '*.csproj'`, the before capture of +`git status --porcelain --untracked-files=all` and `git diff --stat `, and followed by the same two +captures again) + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +## Verbatim printed line + +``` +Formatted 1601 files in 8171ms. +``` + +`format` is a write-mode command: it rewrites tracked source and still exits 0 after rewriting, so the exit code +alone cannot distinguish a clean run from a repairing one. The distinguishing observations are the two derived +comparison lines below, captured before and after the run in the same shell. + +## Derived comparison lines + +PATH_SETS_IDENTICAL: False +DIFFSTAT_IDENTICAL: False + +Both lines are recorded with their values, which is what this task's acceptance requires. `False` on both is the +truthful observation: the formatter rewrote files, so the porcelain path set went from empty to nine entries and +the anchored diffstat changed. It is not a failure signal. The gate on formatting cleanliness is [P3-T2], whose +read-only `check` exit code is the actual pass/fail. + +## Before: `git status --porcelain --untracked-files=all` + +Empty. Phase 2 was committed at `f50fb727`, so the worktree was clean at the start of this task and the +`--intent-to-add` companion had nothing new to stage. + +## After: `git status --porcelain --untracked-files=all` + +``` + M QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs + M QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs + M UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs + M UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs + M UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs + M UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs + M UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs + M UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs + M UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs +``` + +## Scope check on the touched set + +Nine files appear in the post-run porcelain output. All nine are members of this plan's twenty-path Write Set. NO +file outside the Write Set appears, so no revert was required and the repository-wide pass did not widen the scope +boundary [P3-T11] asserts. + +Of those nine, only FIVE carry a content change. The other four were rewritten byte-identically — CSharpier +updated their modification time without changing their bytes — and `git status` reported them modified from the +refreshed stat cache before any content comparison had been made. This was measured at staging time rather than +inferred: `git diff --cached --numstat` returns zero lines for each of the four, and each file's line endings are +uniformly CRLF (CRLF count equals total LF count), so no line-ending rewrite occurred either: + +``` +STAGED_NUMSTAT_LINES=0 CRLF=41 LF_TOTAL=41 QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs +STAGED_NUMSTAT_LINES=0 CRLF=217 LF_TOTAL=217 UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs +STAGED_NUMSTAT_LINES=0 CRLF=176 LF_TOTAL=176 UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs +STAGED_NUMSTAT_LINES=0 CRLF=63 LF_TOTAL=63 UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs +``` + +Those four files were therefore already CSharpier-clean when this pass began, which is consistent with [P3-T2] +finding no drift immediately afterwards. + +## Anchored diffstat delta (before vs after), by file + +Derived by comparing the two `git diff --stat ` captures. Only five files moved; the insertion totals +went from 3305 to 3347, a net +42. + +| Path | before | after | delta | +|---|---|---|---| +| `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs` | 425 | 463 | +38 | +| `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs` | 344 | 346 | +2 | +| `UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs` | 179 | 181 | +2 | +| `UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs` | 92 | 93 | +1 | +| `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs` | 213 | 212 | -1 | +| `UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs` | 176 | 176 | 0, no content change | +| `UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs` | 217 | 217 | 0, no content change | +| `UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs` | 63 | 63 | 0, no content change | +| `QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs` | 41 | 41 | 0, no content change | + +## The three D11-budgeted files were NOT rewritten + +The [P2-T18] interim measurement left one line of headroom on two files, so a formatter rewrite of either would +have breached its budget. The formatter did not touch any of the three: + +- `QuickFiler/Controllers/EfcFormController.cs` — diffstat unchanged at `3 +/-` before and after; absent from the + rewritten set. +- `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` — diffstat unchanged at `41 +/-`; absent from the + rewritten set. +- `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` — diffstat unchanged at `33 --`; absent from the + rewritten set. + +[P3-T10] is the gating re-measurement and confirms the resulting counts. + +## Checked-file count + +The formatter reports 1601 files, which is the [P0-T8] baseline of 1593 plus the eight new `.cs` files this plan +adds (three production, five test). [P3-T2] records the same delta from the read-only `check`. + +Output Summary: The repository-wide CSharpier pass exited 0 and printed `Formatted 1601 files in 8171ms.`. Nine +files appear in the post-run porcelain output, every one of them inside this plan's Write Set, so nothing was +reverted and the scope boundary is unchanged; five of the nine carry a content change and the other four were +rewritten byte-identically, measured at staging time. Both required derived comparison lines are recorded: +`PATH_SETS_IDENTICAL: False` and `DIFFSTAT_IDENTICAL: False`, which is the truthful before/after observation for a +run that rewrote files. The three D11-budgeted files were not among the touched set, so the one line of headroom +[P2-T18] recorded on `EfcFormController.cs` and `FolderPredictor.cs` was not consumed by formatting. + +## Path hygiene (R3) + +No absolute host path, host account name, or machine name appears in this artifact. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t10-sizes.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t10-sizes.md new file mode 100644 index 000000000..4447358a7 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t10-sizes.md @@ -0,0 +1,115 @@ +# [P3-T10] Post-format line counts of every file this plan edited or created + +Timestamp: 2026-09-07T08-12 + +Command: `(Get-Content -LiteralPath ).Count` for each path below + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +CEILING: 500 (applies to *.cs only) + +This audit runs AFTER the [P3-T1] CSharpier pass, because the formatter can change line counts (R9). It supersedes +the [P2-T18] interim measurement, which was taken before the formatter ran, and it is the gating measurement. + +## Production `.cs` + +| Path | [P0-T14] baseline | [P2-T18] pre-format | [P3-T10] post-format | D11 budget | Headroom | Verdict | +|---|---|---|---|---|---|---| +| `UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs` | NOT PRESENT | 63 | 63 | 500 | 437 | met | +| `UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs` | NOT PRESENT | 92 | 93 | 500 | 407 | met | +| `UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs` | 141 | 300 | 302 | 500 | 198 | met | +| `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` | 1003 | 1002 | 1002 | 1003 (no growth) | 1 | met | +| `QuickFiler/Controllers/QfcItemController.FolderHandling.cs` | 312 | 295 | 295 | 500, and [P2-T10] additionally requires at or below its 312 baseline | 205 | met | +| `QuickFiler/Controllers/BreadcrumbBridgeRouter.cs` | 304 | 407 | 407 | 500 | 93 | met | +| `QuickFiler/Controllers/EfcFormController.cs` | 1320 | 1321 | 1321 | 1322 | 1 | met | +| `QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs` | NOT PRESENT | 41 | 41 | 500 | 459 | met | +| `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` | 500 | 467 | 467 | 500 (hard) | 33 | met | + +## Test `.cs` + +| Path | [P0-T14] baseline | [P2-T18] pre-format | [P3-T10] post-format | Budget | Headroom | Verdict | +|---|---|---|---|---|---|---| +| `UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs` | NOT PRESENT | 176 | 176 | 500 | 324 | met | +| `UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs` | NOT PRESENT | 217 | 217 | 500 | 283 | met | +| `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs` | NOT PRESENT | 344 | 346 | 500 | 154 | met | +| `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs` | NOT PRESENT | 213 | 212 | 500 | 288 | met | +| `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs` | NOT PRESENT | 425 | 463 | 500 | 37 | met | +| `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs` | 479 | 480 | 480 | 483 ([P1-T13] +4) | 3 | met | +| `QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs` | 354 | 363 | 363 | 500 | 137 | met | + +## PROJECT-FILE (exempt) + +Recorded as exempt observations rather than asserted against the ceiling, per R8: the 500-line cap in +.claude/rules/general-code-change.md covers production code, test code and reusable script files and does not +reach project files, and .csharpierignore lines 9-14 record that project files are owned by Visual Studio and are +not C# source. CSharpier does not process them either, so these counts are unchanged from [P2-T18]. + +- PROJECT-FILE (exempt): `UtilitiesCS/UtilitiesCS.csproj` = 1317 (baseline 1315, +2 Compile Include) +- PROJECT-FILE (exempt): `QuickFiler/QuickFiler.csproj` = 606 (baseline 605, +1 Compile Include) +- PROJECT-FILE (exempt): `UtilitiesCS.Test/UtilitiesCS.Test.csproj` = 980 (baseline 976, +4 Compile Include) +- PROJECT-FILE (exempt): `QuickFiler.Test/QuickFiler.Test.csproj` = 530 (baseline 529, +1 Compile Include) + +## What the formatter changed + +The [P3-T1] pass moved five of the sixteen `.cs` files in this set, by a net +42 lines overall: + +- `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs` 425 to 463 (+38) +- `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs` 344 to 346 (+2) +- `UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs` 300 to 302 (+2) +- `UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs` 92 to 93 (+1) +- `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs` 213 to 212 (-1) + +The two files carrying only one line of headroom — `QuickFiler/Controllers/EfcFormController.cs` at 1321 against +1322 and `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` at 1002 against 1003 — were NOT rewritten by the +formatter and are unchanged from their pre-format counts, so neither budget was breached. This was the identified +risk in this task and it did not materialise. `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` was +likewise untouched at 467. + +## Smallest remaining headroom + +SMALLEST-REMAINING-HEADROOM: 1 line, on two files. + +- `QuickFiler/Controllers/EfcFormController.cs` — 1321 against its D11 budget of 1322. +- `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` — 1002 against its D11 no-growth budget of 1003. + +Next after those: `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs` at 480 against +its 483 budget (3 lines), then `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs` at 463 +against 500 (37 lines). + +## The three disclosed pre-existing over-ceiling files and their budgets (D11) + +R8 and D11 disclose three files that were ALREADY over the 500-line ceiling before any change in this plan. They +are gated by a per-file budget rather than by the ceiling, because a blanket "at or below 500" assertion would be +unsatisfiable on them: + +| Path | Pre-existing count | D11 budget | Post-format count | Verdict | +|---|---|---|---|---| +| `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` | 1003 | 1003, no growth | 1002 | met, and one line below the pre-existing count | +| `QuickFiler/Controllers/EfcFormController.cs` | 1320 | 1322, that is baseline plus at most two | 1321 | met | +| UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs | 1066 | not touched by this plan | 1066, unchanged | not in the Write Set; disclosed only | + +Neither over-ceiling file is repaired here and neither grew beyond its budget. `FolderPredictor.cs` in fact ends +one line SMALLER than it began, so this change moves it toward the ceiling rather than away from it. + +## Acceptance conditions + +- `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` at or below 500: 467, met. +- `QuickFiler/Controllers/EfcFormController.cs` at or below 1322: 1321, met. +- `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` at or below 1003: 1002, met. +- Every other listed `.cs` file at or below 500: the largest is + `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs` at 463, met. +- Exempt project-file counts recorded but not asserted against the ceiling: done. +- Smallest remaining headroom stated, together with the three disclosed pre-existing over-ceiling files and their + budgets: done. + +Output Summary: Every `.cs` file in this plan's footprint satisfies its D11 budget after the final format pass. No +budget was breached. The two files that entered this task with one line of headroom were not touched by the +formatter and remain at 1321 and 1002 against budgets of 1322 and 1003. The R9 ordering constraint held: +`QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` finishes at 467 and never passed through 501, because +[P2-T3] removed the relocated member before [P2-T14] added the constructor argument to it in the new partial. + +## Path hygiene (R3) + +No absolute host path, host account name, or machine name appears in this artifact. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t11-scope.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t11-scope.md new file mode 100644 index 000000000..3c518e9c3 --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t11-scope.md @@ -0,0 +1,151 @@ +# [P3-T11] Scope boundary of the changed source set + +Timestamp: 2026-09-07T08-15 + +Command: `git add --intent-to-add -- '*.cs' '*.csproj'`; `git diff --name-only -- '*.cs' '*.csproj'`; `git status --porcelain --untracked-files=all -- '*.cs' '*.csproj'`; then per-path `git diff --name-only -- ` and `git status --porcelain --untracked-files=all -- ` over the fifteen paths asserted absent + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +## Anchored diff, name-listing (`git diff --name-only -- '*.cs' '*.csproj'`) + +``` +QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs +QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs +QuickFiler.Test/QuickFiler.Test.csproj +QuickFiler/Controllers/BreadcrumbBridgeRouter.cs +QuickFiler/Controllers/EfcFormController.cs +QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs +QuickFiler/Controllers/QfcItemController.FolderHandling.cs +QuickFiler/Controllers/QfcItemController.ViewerSetup.cs +QuickFiler/QuickFiler.csproj +UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs +UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs +UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs +UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs +UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs +UtilitiesCS.Test/UtilitiesCS.Test.csproj +UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs +UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs +UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs +UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs +UtilitiesCS/UtilitiesCS.csproj +``` + +ENUMERATED-PATH-COUNT: 20 + +## Porcelain status companion (`git status --porcelain --untracked-files=all -- '*.cs' '*.csproj'`) + +``` + M QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs + M QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs + M UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs + M UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs + M UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs + M UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs + M UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs + M UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs + M UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs +``` + +## Why both are listed side by side + +Neither mechanism alone is correct in both states. The anchored diff cannot see an untracked path, which is why +the `git add --intent-to-add` companion runs first. Porcelain status goes empty once a change is committed, which +is why it shows only the nine files the [P3-T1] formatter touched after the Phase 2 commit at `f50fb727` rather +than the whole footprint. The anchored diff is the authoritative enumeration here; the porcelain output is the +untracked-visibility companion and confirms the nine uncommitted rewrites are all inside the same twenty-path set. +The porcelain set is a strict subset of the anchored set, with no path present in one and absent from the other in +the direction that would indicate leakage. + +## The enumerated set is exactly the twenty Write Set paths + +### Nine production paths + +1. `UtilitiesCS/OutlookObjects/Folder/ArchiveStemProjection.cs` — CREATE +2. `UtilitiesCS/OutlookObjects/Folder/ArchiveChainProjection.cs` — CREATE +3. `UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs` — MODIFY +4. `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` — MODIFY +5. `QuickFiler/Controllers/QfcItemController.FolderHandling.cs` — MODIFY +6. `QuickFiler/Controllers/BreadcrumbBridgeRouter.cs` — MODIFY +7. `QuickFiler/Controllers/EfcFormController.cs` — MODIFY +8. `QuickFiler/Controllers/QfcItemController.BreadcrumbWiring.cs` — CREATE +9. `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs` — MODIFY + +### Five new test paths + +10. `UtilitiesCS.Test/OutlookObjects/Folder/ArchiveStemProjectionTests.cs` +11. `UtilitiesCS.Test/OutlookObjects/Folder/ArchiveChainProjectionTests.cs` +12. `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTrimTests.cs` +13. `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorRecentsProjectionTests.cs` +14. `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterScoreJoinTests.cs` + +### Two retargeted test paths + +15. `UtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderHierarchyProviderTests.cs` +16. `QuickFiler.Test/Controllers/QfcItemController.FolderHandlingTests.Part2.cs` + +### Four project files + +17. `UtilitiesCS/UtilitiesCS.csproj` +18. `QuickFiler/QuickFiler.csproj` +19. `UtilitiesCS.Test/UtilitiesCS.Test.csproj` +20. `QuickFiler.Test/QuickFiler.Test.csproj` + +Twenty enumerated, twenty accounted for, none left over in either direction. + +## Per-path no-hunk assertion over the fifteen paths this task names as absent + +Each path was queried individually rather than inferred from absence in the list above, so the assertion is +mechanical. `TRACKED` confirms the path exists in the index at the base commit, which is what makes a zero diff a +real observation rather than the trivially empty result of naming a nonexistent path. + +| Path | TRACKED | anchored diff lines | porcelain lines | +|---|---|---|---| +| UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs | True | 0 | 0 | +| UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.SearchPresentation.cs | True | 0 | 0 | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.cs | True | 0 | 0 | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionSession.Highlight.cs | True | 0 | 0 | +| QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs | True | 0 | 0 | +| QuickFiler/Viewers/BreadcrumbBridgeCoordinator.Search.cs | True | 0 | 0 | +| QuickFiler/Controllers/BreadcrumbBridgeRouter.Selection.cs | True | 0 | 0 | +| QuickFiler/Controllers/BreadcrumbBridgeRouter.Arrows.cs | True | 0 | 0 | +| UtilitiesCS/OutlookObjects/Folder/BreadcrumbRowBuilder.cs | True | 0 | 0 | +| UtilitiesCS/OutlookObjects/Folder/ArchiveStemContract.cs | True | 0 | 0 | +| UtilitiesCS/OutlookObjects/Folder/FolderTreeSnapshotQueries.cs | True | 0 | 0 | +| UtilitiesCS/OutlookObjects/Folder/IFolderHierarchyProvider.cs | True | 0 | 0 | +| UtilitiesCS/OutlookObjects/Folder/FolderMinimalWrapper.cs | True | 0 | 0 | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue439Tests.cs | True | 0 | 0 | +| QuickFiler.Test/Controllers/BreadcrumbBridgeRouterIssue439Tests.Activation.cs | True | 0 | 0 | + +SIBLING-OWNED-FILES-WITH-A-HUNK: 0 of 6 +D8-NO-HUNK-TEST-FILES-WITH-A-HUNK: 0 of 2 +OTHER-EXCLUDED-FILES-WITH-A-HUNK: 0 of 7 + +The first six rows are the sibling-owned files D1 names, which a concurrent sibling item owns and which decision +D-B forbids this item from editing. The last two rows are the two #439 Efc router test files D8 marks NO HUNK: the +AC1/AC2 trim lives inside the provider's GetAncestorChainAsync, below the strict-mock boundary every test in both +files uses, so neither file could need an edit, and editing their shared `Chain` helper would have broken the +unrelated #614 boundary test Issue439SlashOnlyArchiveRootPreservesFullHierarchySelection. All ten tests of that +partial class pass in [P2-T16] and in the [P3-T5] full run, which is the behavioural confirmation that the +no-hunk finding was correct rather than an omission. + +## Scope of this enumeration (R7) + +The pathspec is `'*.cs' '*.csproj'` only. This plan additionally writes evidence artifacts under +`/evidence/` and checks off AC boxes in `spec.md`, and it updates `issue.md` and the plan checklist; none +of those is a source file and none is in this enumeration. That is the intended scope, not an omission. Because +the QuickFiler resources directory contains no `.cs` and no `.csproj` file, an assertion that it is absent from +THIS enumeration would be true for every possible execution and would gate nothing, which is why [P3-T20] makes +the AC8 asset check against its own separately anchored diff rather than reading it off this artifact. + +Output Summary: The changed source set under the R7 pathspec is exactly the twenty Write Set paths — nine +production, five new test, two retargeted test and four project files — with nothing extra and nothing missing. +All fifteen paths this task names as absent were queried individually and every one returned zero anchored-diff +lines and zero porcelain lines while being confirmed tracked at the base commit. The six sibling-owned files carry +no hunk, and the two issue-439 test files carry no hunk. + +## Path hygiene (R3) + +No absolute host path, host account name, or machine name appears in this artifact. diff --git a/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t12-ac8-verification.md b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t12-ac8-verification.md new file mode 100644 index 000000000..8e52be39c --- /dev/null +++ b/docs/features/active/2026-09-06-breadcrumb-lineage-below-archive-root-and-suggestion-path-consistency-799/evidence/qa-gates/p3-t12-ac8-verification.md @@ -0,0 +1,138 @@ +# [P3-T12] AC8 verification — the renderer does not alter a leading underscore + +Timestamp: 2026-09-07T08-20 + +Command: git grep -n -E 'Replace\(["'']_|letter-spacing|text-transform|first-letter|word-break|word-spacing' -- "UtilitiesCS/*.cs" "UtilitiesCS/*.html" "UtilitiesCS/*.css" "QuickFiler/*.cs" "QuickFiler/*.html" "QuickFiler/*.css" "ToDoModel/*.cs" "TaskMaster/*.cs" "Tags/*.cs" "TaskVisualization/*.cs" + +EXIT_CODE: 1 + +ExpectedExitCode: 1 + +`git grep` exits 1 when it matches nothing, and zero matches is this task's SUCCESS outcome. The expectation is +declared explicitly so a passing gate is not normalised to `fail` by an evidence collector that defaults the +expectation to 0. + +## Search result + +OUTPUT-LINES: 0 + +The command produced no output lines at all. The regex, as passed to `git grep -n -E`, was: + +``` +Replace\(["']_|letter-spacing|text-transform|first-letter|word-break|word-spacing +``` + +The pattern was constructed from character codes inside the PowerShell block rather than typed as a literal, +because a bash-hosted single-quoted `pwsh -Command` payload cannot carry an embedded single quote. The pattern +actually passed to git is printed above verbatim from the run, so what was searched is recorded rather than +assumed. + +## The five traced transformations, each re-verified against the current tree + +Each claim below was checked directly in this pass; none is transcribed from the specification without +confirmation. + +### 1. The verbatim splitter inserts nothing and trims nothing + +UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs, `SplitVerbatim` at lines 242-246, with the +`Split` call itself at line 244: + +``` +private static string[] SplitVerbatim(string verbatimText) +{ + var parts = verbatimText.Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries); + return parts.Length == 0 ? new[] { verbatimText } : parts; +} +``` + +It splits on the path separators and removes empty entries. `String.Split` copies the characters between +separators unchanged; `RemoveEmptyEntries` only discards zero-length parts. A leading underscore is not a +separator and is not zero-length, so it survives verbatim, and no whitespace is introduced anywhere. The +zero-parts fallback returns the original string by reference, so it cannot transform either. + +### 2. The JSON serializer escapes only the double quote, the backslash and control characters + +UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs, `Formatting = Formatting.None` at line 41 and +`JsonConvert.SerializeObject(message, OutboundSettings)` at line 58. `Formatting.None` emits no indentation and, +more to the point, adds no whitespace INSIDE a string value under any formatting setting. Json.NET's string writer +escapes the double quote, the backslash and the C0 control characters; the underscore is none of those and is +written through unchanged. + +### 3. The QuickFiler page assigns segment text through `textContent` + +QuickFiler/Resources/FolderBreadcrumb.html, seven assignments, all through the DOM `textContent` property: + +``` +L253: element.textContent = cell.text; +L262: element.textContent = ""; +L266: element.textContent = cell.kind === "plus" ? "+" : "-"; +L299: selectedPath.textContent = state.selectedFolder; +L309: pct.textContent = row.percentText; +L327: name.textContent = subfolder.displayName; +L357: list.textContent = ""; +``` + +Line 253 is the segment-text assignment. `textContent` sets the node's character data literally: it performs no +HTML parsing, no entity decoding and no escaping, so it cannot introduce a space after an underscore. The page +contains no `innerHTML` assignment for segment text. + +### 4. The Efc page encodes ampersand, less-than, greater-than and quote characters only + +UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs routes every user-visible string through +`WebUtility.HtmlEncode` — at lines 99, 124, 134, 186, 188, 209, 224 and 226. The segment display name is line 188 +and the full path line 186. `WebUtility.HtmlEncode` replaces the markup-significant characters and characters +above the ASCII range with numeric or named references; the underscore (U+005F) and the space (U+0020) are neither +markup-significant nor above the ASCII range, so both pass through byte-for-byte. No non-breaking space is emitted: +a case-insensitive search of the file for `nbsp` returned nothing. + +### 5. Neither stylesheet contains a spacing or casing transform + +There are exactly two breadcrumb stylesheets, both embedded rather than standalone `.css` files: the `