diff --git a/QuickFiler.Test/Controllers/KaStringAsyncTests.cs b/QuickFiler.Test/Controllers/KaStringAsyncTests.cs index 525542b2d..9762cd715 100644 --- a/QuickFiler.Test/Controllers/KaStringAsyncTests.cs +++ b/QuickFiler.Test/Controllers/KaStringAsyncTests.cs @@ -89,13 +89,44 @@ public void KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue() result.Should().BeTrue("a substring match returns true"); updateArg .Should() - .Be("b", "Update receives Key.Substring(other.Length - 1, 1) => index 1 => \"b\""); + .Be( + "b", + "Update receives the last character of the matched span (Key.IndexOf(\"ab\", StringComparison.Ordinal) + other.Length - 1 = 1) => \"b\"" + ); ka.Activated.Should() .BeTrue( "the contains-match branch returns before the trailing Activated = false reset" ); } + [TestMethod] + public void KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter() + { + // Intent: issue #583 regression. Branch 1's Update argument was computed as + // Key.Substring(other.Length - 1, 1), which is only correct when other is a prefix + // of Key. For Key="01" and other="1", the probe matches at index 1, not index 0. + // Before the fix this yielded "0"; after the fix it yields "1". + + // Arrange + string updateArg = null; + var ka = NewKa("01", update: s => updateArg = s); + ka.Activated = true; + + // Act + var result = ka.KeyEquals("1"); + + // Assert + result.Should().BeTrue("a substring match returns true"); + updateArg + .Should() + .Be( + "1", + "Update receives the last character of the matched span (Key.IndexOf(\"1\", " + + "StringComparison.Ordinal) + other.Length - 1 = 1), not the pre-fix " + + "prefix-only offset that yielded \"0\"" + ); + } + [TestMethod] public void KeyEquals_ContainsMatchWhileNotActivated_ReturnsTrueWithoutUpdate() { diff --git a/QuickFiler/Controllers/KaStringAsync.cs b/QuickFiler/Controllers/KaStringAsync.cs index 9951e6002..a0132b09a 100644 --- a/QuickFiler/Controllers/KaStringAsync.cs +++ b/QuickFiler/Controllers/KaStringAsync.cs @@ -78,8 +78,10 @@ public bool Activated /// /// /// Argument contract. must be non-null and non-empty. The - /// guard clause at the top of this method rejects both fail-fast, so branch 1's substring - /// offset expression is never evaluated with a negative start index. + /// guard clause at the top of this method rejects both fail-fast, so branch 1's derived + /// offset (Key.IndexOf(other) plus the matched length) is never evaluated with a + /// negative start index: IndexOf is non-negative because branch 1 only runs when + /// Contains already matched, and other.Length is at least 1 because of the guard above. /// /// /// Consequence for callers. KbdActions methods whose key type is @@ -125,7 +127,12 @@ public bool KeyEquals(string other) if (Key.Contains(other)) { if (Activated && Update is not null) - Update(Key.Substring(other.Length - 1, 1)); + Update( + Key.Substring( + Key.IndexOf(other, StringComparison.Ordinal) + other.Length - 1, + 1 + ) + ); return true; } else if (other.Length == 1) diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md new file mode 100644 index 000000000..00c477c4e --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md @@ -0,0 +1,135 @@ +# Coverage Baseline (P0-T8) + +- Timestamp: 2026-09-13T01-00 +- Command: collect --output + --output-format cobertura --settings -- + QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /InIsolation + /Settings:scripts\vscode\TaskMaster.cli.runsettings + "/TestCaseFilter:TestCategory!=LiveOutlook" +- EXIT_CODE: 0 + +## Deviation note — dotnet-coverage module-exclude settings + +The first attempt at this capture, using only the TaskMaster CLI runsettings file with no +dotnet-coverage module excludes, failed 3 of 1393 tests +(InitEmailQueue_ZeroBatchSize_ReturnsEmptyListWithoutThrowing, +InitEmailQueue_ZeroBatchSize_StillStartsBackgroundWorker, +InitEmailQueue_PositiveBatchSize_RetainsExistingProjectionAndFrameDrop) with a +System.TypeInitializationException on Deedle.Reflection / netstandard, Version=2.1.0.0. This is +a known, pre-existing, environment-level failure mode of dotnet-coverage's own instrumentation +against Deedle/F# assemblies, unrelated to KaStringAsync and unrelated to this branch's diff +(confirmed no source diff from origin/main at the time of this capture). The repository's own +TaskMaster.runsettings already carries a matching Deedle/FSharp module-exclude block for the +built-in Code Coverage collector; dotnet-coverage's own instrumentation does not read that file, +so an equivalent exclude was supplied to dotnet-coverage collect via its own --settings file +(ModulePaths/Exclude for .*Deedle.*, .*FSharp.*, .*Castle\.Core.*, .*FluentAssertions.*, +.*Moq.*, .*Microsoft\.Testing.*, .*MSTest.*), written to a temp-directory file, never added to +the repository. With that settings file supplied, the same capture reported "Test Run +Successful.", 1393/1393 Passed. + +## Test run result + +``` +Test Run Successful. +Total tests: 1393 + Passed: 1393 + Total time: 15.6062 Seconds +``` + +## Root coverage figures + +- Root line-rate: 0.4298999577286177 +- Root branch-rate: 0.24095967959333 + +## KaStringAsync.cs covered/total + +- Covered: 60 +- Total: 60 + +## Per-line hits projection + +| Line | Hits | +|---|---| +| 12 | 1 | +| 14 | 1 | +| 15 | 1 | +| 16 | 1 | +| 17 | 1 | +| 18 | 1 | +| 19 | 1 | +| 20 | 1 | +| 21 | 1 | +| 22 | 1 | +| 23 | 1 | +| 24 | 1 | +| 25 | 1 | +| 26 | 1 | +| 27 | 1 | +| 32 | 1 | +| 33 | 1 | +| 39 | 1 | +| 40 | 1 | +| 46 | 1 | +| 47 | 1 | +| 50 | 1 | +| 53 | 1 | +| 54 | 1 | +| 107 | 1 | +| 110 | 1 | +| 111 | 1 | +| 112 | 1 | +| 115 | 1 | +| 116 | 1 | +| 117 | 1 | +| 118 | 1 | +| 119 | 1 | +| 120 | 1 | +| 121 | 1 | +| 122 | 1 | +| 125 | 1 | +| 126 | 1 | +| 127 | 1 | +| 128 | 1 | +| 129 | 1 | +| 131 | 1 | +| 132 | 1 | +| 133 | 1 | +| 134 | 1 | +| 135 | 1 | +| 136 | 1 | +| 137 | 1 | +| 138 | 1 | +| 139 | 1 | +| 140 | 1 | +| 141 | 1 | +| 142 | 1 | +| 143 | 1 | +| 144 | 1 | +| 145 | 1 | +| 150 | 1 | +| 151 | 1 | +| 157 | 1 | +| 158 | 1 | + +## Raw output disposition + +- Raw-output location (relative to the per-user temp directory root): p583-p0t8b/coverage.cobertura.xml +- Prefix comparison (raw-output full path begins with repository root full path): False +- Post-deletion existence check of the raw file: False (absent) +- Directory listing of evidence/baseline/ after deletion (entry names only): + - coverage-tool-probe.md + - csharpier-check.md + - dotnet-bootstrap.md + - msbuild-analyzers.md + - msbuild-nullable.md + - nuget-restore.md + - phase0-instructions-read.md + +## Output Summary + +Exit code 0; inner test run "Test Run Successful." with 0 Failed (1393/1393 Passed, after +supplying a dotnet-coverage module-exclude settings file to work around a known pre-existing +Deedle/FSharp instrumentation crash unrelated to this change); root line-rate +0.4298999577286177, root branch-rate 0.24095967959333; KaStringAsync.cs covered/total 60/60; +per-line hits projection holds 60 rows, no repeated line number; raw output confirmed outside +the repository root and deleted; evidence/baseline/ directory listing contains no .xml entry. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md new file mode 100644 index 000000000..c7d49d84c --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md @@ -0,0 +1,17 @@ +# Coverage Tool Probe + +- Timestamp: 2026-09-13T00-15 +- Command: Get-Command dotnet-coverage(.exe), then its version switch +- EXIT_CODE: 0 + +## Result + +- Present: yes (already installed as a global dotnet tool; no install step was needed) +- Resolved path: \.dotnet\tools\dotnet-coverage.exe +- Version string: 18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342 + +## Output Summary + +dotnet-coverage is present as a global tool, resolved path recorded above, version string +18.10.0, observed exit code 0 for the version-switch invocation, matching the recorded shape at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/coverage-tool-probe.2026-08-22T09-32.md. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md new file mode 100644 index 000000000..9a352032b --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md @@ -0,0 +1,20 @@ +# CSharpier Check (Baseline) + +- Timestamp: 2026-09-13T00-20 +- Command: tool run csharpier check . (run from repository root, + read-only) +- EXIT_CODE: 0 + +## Verbatim output + +``` +Checked 1624 files in 5999ms. +``` + +## Output Summary + +Files-checked count: 1624. Files-needing-formatting count: 0 (no per-file line appeared ahead +of the summary line). Exit code 0, matching the recorded clean-tree shape ("Checked 1517 files +in 6621ms.", exit code 0) at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/csharpier-check.2026-08-22T09-19.md; +the higher file count here reflects the larger repository tree at this later point in time. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md new file mode 100644 index 000000000..f2b527d99 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md @@ -0,0 +1,31 @@ +# Dotnet Bootstrap + +- Timestamp: 2026-09-13T00-05 + +## Step 1 — SDK resolution + +The per-worktree .dotnet-sdk junction did not resolve (Test-Path returned False before this +task ran). Fallback provisioner scripts/vscode/Install-RepoDotNetSdk.ps1 was run. + +- Command: pwsh -NoProfile -File scripts/vscode/Install-RepoDotNetSdk.ps1 +- Output: "Downloading .NET SDK 8.0.205 from + https://builds.dotnet.microsoft.com/dotnet/Sdk/8.0.205/dotnet-sdk-8.0.205-win-x64.zip...", + "Installed repo-local .NET SDK 8.0.205 to + \.dotnet-sdk." +- Resolved dotnet executable: .dotnet-sdk/dotnet.exe (repo-relative), version switch confirms + 8.0.205. +- EXIT_CODE: 0 + +## Step 2 — Tool restore + +- Command: tool restore (run from repository root) +- EXIT_CODE: 0 +- Output: "Tool 'csharpier' (version '1.2.6') was restored. Available commands: csharpier" / + "Restore was successful." — matches the recorded success shape at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/dotnet-tool-restore.2026-08-22T09-18.md. + +## Output Summary + +Both steps succeeded: repo-local .NET SDK 8.0.205 installed for this worktree; CSharpier 1.2.6 +restored via dotnet-tools.json manifest, exit code 0, matching the archived precedent's success +text verbatim. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md new file mode 100644 index 000000000..b21be60df --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md @@ -0,0 +1,38 @@ +# KbdActionsTests Baseline (P0-T9) + +- Timestamp: 2026-09-13T01-05 +- Command: QuickFiler.Test\bin\Debug\QuickFiler.Test.dll + /InIsolation /Settings:scripts\vscode\TaskMaster.cli.runsettings + "/TestCaseFilter:FullyQualifiedName~KbdActionsTests" +- EXIT_CODE: 0 + +## Verbatim result + +``` +Test Run Successful. +Total tests: 4 + Passed: 4 + Total time: 1.2679 Seconds +``` + +## Per-class reporting + +| Class | Total | Passed | Failed | Exit | +|---|---|---|---|---| +| KbdActionsTests | 4 | 4 | 0 | 0 | + +Individual tests: Add_WhenSourceAndStoredKeysAreDistinct_DoesNotTreatSubstringAsDuplicate, +Add_WhenSourceAndStoredKeyAreExactDuplicate_ThrowsArgumentException, +EnumerableConstructor_WhenStoredKeysDifferButKeyEqualsOverlaps_DoesNotThrow, +FilterKeys_WhenDistinctStoredKeysCoexist_PreservesKeyboardMatchingSemantics (the pinned test). + +## Output Summary + +Exit code 0; verdict "Test Run Successful."; 4 Passed, 0 Failed for the KbdActionsTests class, +including the pinned test FilterKeys_WhenDistinctStoredKeysCoexist_PreservesKeyboardMatchingSemantics. +This baseline count (4) is expected to differ from the archived precedent's count of 3 on +2026-08-22 (docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/vstest-baseline.2026-08-22T09-18.md), +because one test method (Add_WhenSourceAndStoredKeyAreExactDuplicate_ThrowsArgumentException or +a sibling) has been added to the pinned file since that baseline was recorded; per the plan's +own acceptance text this task's count is not compared against that archived figure. No file was +diffed to obtain this result; it is derived solely from the test-run outcome. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md new file mode 100644 index 000000000..828311cfe --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md @@ -0,0 +1,34 @@ +# MSBuild Analyzer Rebuild (Baseline) + +- Timestamp: 2026-09-13T00-30 +- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug + "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true + /flp:logfile=;verbosity=detailed +- EXIT_CODE: 0 + +## Verdict line + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +## Log-line counts (from the detailed file logger) + +- "Skipping target ... CoreCompile" occurrences: 0 +- "CoreCompile:" occurrences: 130 (proves the rebuild was not vacuous) +- Total warning count: 0 (console verdict "0 Warning(s)"; no analyzer/compiler warning lines + present in the detailed log for this run — the five System.Reactive + PackagesConfigCheck.targets target-invocation trace lines present in the log are target-graph + trace lines, not warning diagnostics, and none is followed by a warning-severity message text) + +## Output Summary + +Exit code 0; verdict "Build succeeded."; 0 "Skipping target CoreCompile" occurrences; 130 +"CoreCompile:" occurrences (non-vacuous rebuild); recorded warning count is 0, which is the +ceiling P5-T3 compares against. This differs numerically from the archived precedent's 5 +System.Reactive packages.config warnings at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-analyzers.2026-08-22T09-21.md, +but is consistent in kind (0 skip / >=9 CoreCompile / 0 errors); the numeric warning count is +recorded as observed rather than assumed to match the archived figure. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md new file mode 100644 index 000000000..085c20783 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md @@ -0,0 +1,27 @@ +# MSBuild Nullable Rebuild (Baseline) + +- Timestamp: 2026-09-13T00-35 +- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug + "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true + /flp:logfile=;verbosity=detailed +- EXIT_CODE: 0 + +## Verdict line + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +## Log-line count + +- "Skipping target ... CoreCompile" occurrences: 0 + +## Output Summary + +Exit code 0; verdict "Build succeeded."; "Skipping target CoreCompile" count of exactly 0 +(141 "CoreCompile:" occurrences, confirming the rebuild was not vacuous). Matches the recorded +shape (0 skip / 0 errors) at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-nullable.2026-08-22T09-23.md. +No nullable opt-in property (Nullable=enable) was passed, per the Command Reference. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md new file mode 100644 index 000000000..a4591bce7 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md @@ -0,0 +1,44 @@ +# NuGet Restore + +- Timestamp: 2026-09-13T00-10 +- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Restore /m /p:RestorePackagesConfig=true (run + from repository root) +- EXIT_CODE: 0 + +## Verbatim summary lines + +``` +Installed: + 172 package(s) to packages.config projects +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +## Package-directory count verification + +Command: (Get-ChildItem -Path packages -Directory).Count +Result: 172 + +## Output Summary + +Restore command exit code 0; 172 packages installed to a fresh (previously absent) packages +directory in this worktree; post-restore package-directory count is 172, at or above the +required floor of 100 (and consistent in kind with the archived precedent's 265-directory / +150-floor observation, allowing for this worktree's smaller pre-existing package set before this +restore). + +## Pre-existing analyzer HintPath skew — provisioned, no tracked file touched + +15 first-party csproj files (including QuickFiler/QuickFiler.csproj and +QuickFiler.Test/QuickFiler.Test.csproj) reference `` HintPath +Meziantou.Analyzer.3.0.203, a version the fresh packages.config restore above did not install +(it installed only 3.0.235). Confirmed pre-existing: `git diff --name-only +origin/main...HEAD -- "*.csproj" "*/packages.config"` returned no output, so this branch did +not introduce the skew. Remedy: `nuget install Meziantou.Analyzer -Version 3.0.203 +-OutputDirectory packages -DependencyVersion Ignore`, exit code 0, installed into the +gitignored packages/ directory. `git status --porcelain -- packages "*.csproj" +"*/packages.config"` is empty, so no tracked file changed. All five analyzer package families +(Meziantou.Analyzer, Roslynator.Analyzers, SonarAnalyzer.CSharp, AsyncFixer, +Microsoft.CodeAnalysis.BannedApiAnalyzers) referenced anywhere in the solution's csproj files +now resolve to a present packages/ directory. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md new file mode 100644 index 000000000..5a4d32c35 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md @@ -0,0 +1,25 @@ +# Phase 0 — Instructions Read + +- Timestamp: 2026-09-13T00-00 +- Policy Order: (1) CLAUDE.md, (2) .claude/rules/general-code-change.md, (3) + .claude/rules/general-unit-test.md, (4) .claude/rules/quality-tiers.md, (5) + .claude/rules/tonality.md, (6) .claude/rules/csharp.md + +## Files read, in full, end to end + +1. CLAUDE.md +2. .claude/rules/general-code-change.md +3. .claude/rules/general-unit-test.md +4. .claude/rules/quality-tiers.md +5. .claude/rules/tonality.md +6. .claude/rules/csharp.md +7. docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md +8. docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md +9. docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md + +## Source files read as plain context (not write targets of this task) + +10. QuickFiler/Controllers/KaStringAsync.cs +11. QuickFiler.Test/Controllers/KaStringAsyncTests.cs + +No policy file was modified by this task. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md new file mode 100644 index 000000000..fd1c92979 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md @@ -0,0 +1,26 @@ +# Acceptance Criteria Status Summary (P5-T14) + +- Timestamp: 2026-09-13T02-25 + +### Acceptance Criteria Status + +- Source: docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md +- Total AC items: 6 +- Checked off (delivered): 6 +- Remaining (unchecked): 0 +- Items remaining: none + +## Per-criterion verification basis + +- AC1: verified by P2-T1's diff (guard text unchanged, no StartsWith introduced). +- AC2: verified by P2-T1's diff and P4-T1's green run. +- AC3: verified by P1-T1 (test added) and P4-T1 (passes post-fix). +- AC4: verified by P3-T1's reword (asserted value unchanged) and P4-T1's green run. +- AC5: verified by P0-T9 and P5-T6's matching Passed counts (0 delta). +- AC6: verified by P5-T1 through P5-T7's four gate artifacts (CSharpier check, analyzer + rebuild, nullable rebuild, MSTest run, coverage delta). + +## Output Summary + +Total AC items 6; Checked off 6; Remaining 0. All six acceptance criteria in spec.md are +checked. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md new file mode 100644 index 000000000..9c04387e4 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md @@ -0,0 +1,173 @@ +# Coverage Delta (P5-T7) + +- Timestamp: 2026-09-13T02-20 + +Computed entirely from the two markdown evidence artifacts (coverage-baseline.md at P0-T8, +coverage-postchange.md at P5-T5); no raw coverage document was read (both raw outputs were +deleted by the tasks that produced them). + +## (a) Instrumented-run line-rate / branch-rate delta + +| | Baseline (P0-T8) | Post-change (P5-T5) | Delta | +|---|---|---|---| +| Root line-rate | 0.4298999577286177 | 0.4300204268507431 | +0.0001204691221254 | +| Root branch-rate | 0.24095967959333 | 0.24095967959333 | 0 | + +## (b) KaStringAsync.cs covered/total, before and after + +| | Baseline (P0-T8) | Post-change (P5-T5) | +|---|---|---| +| Covered | 60 | 65 | +| Total | 60 | 65 | + +Both 100% (60/60 baseline, 65/65 post-change); the 5-line increase in the total is the new +regression test causing 5 additional lines (the multi-line rewritten Update(...) call body) to +be counted as executable, replacing the 1-line pre-fix call. + +## (c) Changed-line coverage + +Anchored zero-context diff of QuickFiler/Controllers/KaStringAsync.cs against origin/main: + +``` +@@ -81,2 +81,4 @@ namespace QuickFiler.Controllers +- /// guard clause at the top of this method rejects both fail-fast, so branch 1's substring +- /// offset expression is never evaluated with a negative start index. ++ /// guard clause at the top of this method rejects both fail-fast, so branch 1's derived ++ /// offset (Key.IndexOf(other) plus the matched length) is never evaluated with a ++ /// negative start index: IndexOf is non-negative because branch 1 only runs when ++ /// Contains already matched, and other.Length is at least 1 because of the guard above. +@@ -128 +130,6 @@ namespace QuickFiler.Controllers +- Update(Key.Substring(other.Length - 1, 1)); ++ Update( ++ Key.Substring( ++ Key.IndexOf(other, StringComparison.Ordinal) + other.Length - 1, ++ 1 ++ ) ++ ); +``` + +Added (post-change) line numbers: 81, 82, 83, 84, 130, 131, 132, 133, 134, 135. + +Cross-referenced against coverage-postchange.md's per-line hits projection (that file's +executable lines only): + +| Added line | Row present in projection? | Hits | +|---|---|---| +| 81 | No (non-executable — doc comment) | excluded | +| 82 | No (non-executable — doc comment) | excluded | +| 83 | No (non-executable — doc comment) | excluded | +| 84 | No (non-executable — doc comment) | excluded | +| 130 | Yes | 1 | +| 131 | Yes | 1 | +| 132 | Yes | 1 | +| 133 | Yes | 1 | +| 134 | Yes | 1 | +| 135 | Yes | 1 | + +- Denominator (added lines appearing as a row in the projection): 6 (130-135) +- Numerator (of those, hits >= 1): 6 +- Changed-line coverage: 6 / 6 = 100% +- Excluded as non-executable (added lines with no row in the projection): 81, 82, 83, 84 — the + reworded doc-comment lines from P3-T2, as expected. + +## Per-line hits projection comparison (baseline vs. post-change) + +Each baseline line number outside the removed hunk range is mapped to its post-change line +number by the net added-minus-removed shift of the hunks that precede it: + +- Lines 1-80 (before both hunks): shift 0. +- Lines 83-127 (after hunk 1's removed range 81-82, before hunk 2): shift +2 (hunk 1 removed 2, + added 4). +- Line 128 (inside hunk 2's removed range): no post-change counterpart — excluded from this + comparison, covered by (c) via its replacement lines 130-135. +- Lines >= 129 (after both hunks): shift +7 (hunk 1 net +2, hunk 2 net +5: removed 1, added 6). + +| Baseline line | Baseline hits | Mapped post-change line | Post-change hits | +|---|---|---|---| +| 12 | 1 | 12 | 1 | +| 14 | 1 | 14 | 1 | +| 15 | 1 | 15 | 1 | +| 16 | 1 | 16 | 1 | +| 17 | 1 | 17 | 1 | +| 18 | 1 | 18 | 1 | +| 19 | 1 | 19 | 1 | +| 20 | 1 | 20 | 1 | +| 21 | 1 | 21 | 1 | +| 22 | 1 | 22 | 1 | +| 23 | 1 | 23 | 1 | +| 24 | 1 | 24 | 1 | +| 25 | 1 | 25 | 1 | +| 26 | 1 | 26 | 1 | +| 27 | 1 | 27 | 1 | +| 32 | 1 | 32 | 1 | +| 33 | 1 | 33 | 1 | +| 39 | 1 | 39 | 1 | +| 40 | 1 | 40 | 1 | +| 46 | 1 | 46 | 1 | +| 47 | 1 | 47 | 1 | +| 50 | 1 | 50 | 1 | +| 53 | 1 | 53 | 1 | +| 54 | 1 | 54 | 1 | +| 107 | 1 | 109 | 1 | +| 110 | 1 | 112 | 1 | +| 111 | 1 | 113 | 1 | +| 112 | 1 | 114 | 1 | +| 115 | 1 | 117 | 1 | +| 116 | 1 | 118 | 1 | +| 117 | 1 | 119 | 1 | +| 118 | 1 | 120 | 1 | +| 119 | 1 | 121 | 1 | +| 120 | 1 | 122 | 1 | +| 121 | 1 | 123 | 1 | +| 122 | 1 | 124 | 1 | +| 125 | 1 | 127 | 1 | +| 126 | 1 | 128 | 1 | +| 127 | 1 | 129 | 1 | +| 128 | 1 | (removed hunk range — no counterpart; covered by (c)) | n/a | +| 129 | 1 | 136 | 1 | +| 131 | 1 | 138 | 1 | +| 132 | 1 | 139 | 1 | +| 133 | 1 | 140 | 1 | +| 134 | 1 | 141 | 1 | +| 135 | 1 | 142 | 1 | +| 136 | 1 | 143 | 1 | +| 137 | 1 | 144 | 1 | +| 138 | 1 | 145 | 1 | +| 139 | 1 | 146 | 1 | +| 140 | 1 | 147 | 1 | +| 141 | 1 | 148 | 1 | +| 142 | 1 | 149 | 1 | +| 143 | 1 | 150 | 1 | +| 144 | 1 | 151 | 1 | +| 145 | 1 | 152 | 1 | +| 150 | 1 | 157 | 1 | +| 151 | 1 | 158 | 1 | +| 157 | 1 | 164 | 1 | +| 158 | 1 | 165 | 1 | + +Every mapped line retains hits >= 1; no baseline line with hits >= 1 maps to a post-change hits +of 0. No regression on changed lines. + +## Companion git-status porcelain listing + +``` + M docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md +?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md +?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md +``` + +No untracked file escapes the anchored diff's tracked-file scope: the only tracked change is +the plan file itself (this task's own check-off work in progress), and the two untracked paths +are this phase's own evidence artifacts, both within this feature's evidence subtree. + +## Output Summary + +Root line-rate rose by +0.0001204691221254 (0.4298999577286177 -> 0.4300204268507431); root +branch-rate unchanged (0.24095967959333). KaStringAsync.cs covered/total: 60/60 baseline, 65/65 +post-change (both 100%). Changed-line coverage: 6/6 = 100% over a 6-line denominator (added +lines 130-135), with lines 81-84 (P3-T2 doc-comment reword) correctly excluded as +non-executable. Projection comparison: every one of the 59 comparable baseline lines (excluding +line 128, inside the removed hunk range and covered by (c)) maps to a post-change line with an +identical hits value of 1 — no regression on any previously-covered line. Companion +git-status porcelain listing confirms no untracked path outside this feature's evidence +subtree. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md new file mode 100644 index 000000000..293574f9b --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md @@ -0,0 +1,125 @@ +# Coverage Post-Change (P5-T5) + +- Timestamp: 2026-09-13T02-05 +- Command: collect --output + --output-format cobertura --settings -- + QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /InIsolation + /Settings:scripts\vscode\TaskMaster.cli.runsettings + "/TestCaseFilter:TestCategory!=LiveOutlook" +- EXIT_CODE: 0 + +## Deviation note + +Identical dotnet-coverage module-exclude settings file as used at P0-T8 was supplied, for the +same pre-existing Deedle/FSharp instrumentation reason recorded there. + +## Test run result + +``` +Test Run Successful. +Total tests: 1394 + Passed: 1394 + Total time: 14.4300 Seconds +``` + +(1394 total vs. the P0-T8 baseline's 1393 — the one added test is the new +KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter regression method.) + +## Root coverage figures + +- Root line-rate: 0.4300204268507431 +- Root branch-rate: 0.24095967959333 + +## KaStringAsync.cs covered/total (post-change) + +- Covered: 65 +- Total: 65 + +## Per-line hits projection + +| Line | Hits | +|---|---| +| 12 | 1 | +| 14 | 1 | +| 15 | 1 | +| 16 | 1 | +| 17 | 1 | +| 18 | 1 | +| 19 | 1 | +| 20 | 1 | +| 21 | 1 | +| 22 | 1 | +| 23 | 1 | +| 24 | 1 | +| 25 | 1 | +| 26 | 1 | +| 27 | 1 | +| 32 | 1 | +| 33 | 1 | +| 39 | 1 | +| 40 | 1 | +| 46 | 1 | +| 47 | 1 | +| 50 | 1 | +| 53 | 1 | +| 54 | 1 | +| 109 | 1 | +| 112 | 1 | +| 113 | 1 | +| 114 | 1 | +| 117 | 1 | +| 118 | 1 | +| 119 | 1 | +| 120 | 1 | +| 121 | 1 | +| 122 | 1 | +| 123 | 1 | +| 124 | 1 | +| 127 | 1 | +| 128 | 1 | +| 129 | 1 | +| 130 | 1 | +| 131 | 1 | +| 132 | 1 | +| 133 | 1 | +| 134 | 1 | +| 135 | 1 | +| 136 | 1 | +| 138 | 1 | +| 139 | 1 | +| 140 | 1 | +| 141 | 1 | +| 142 | 1 | +| 143 | 1 | +| 144 | 1 | +| 145 | 1 | +| 146 | 1 | +| 147 | 1 | +| 148 | 1 | +| 149 | 1 | +| 150 | 1 | +| 151 | 1 | +| 152 | 1 | +| 157 | 1 | +| 158 | 1 | +| 164 | 1 | +| 165 | 1 | + +## Raw output disposition + +- Raw-output location (relative to the per-user temp directory root): p583-p5t5/coverage.cobertura.xml +- Prefix comparison (raw-output full path begins with repository root full path): False +- Post-deletion existence check of the raw file: False (absent) +- Directory listing of evidence/qa-gates/ after deletion (entry names only): + - csharpier-check.md + - csharpier-format.md + - msbuild-analyzers.md + - msbuild-nullable.md + +## Output Summary + +Exit code 0; inner test run "Test Run Successful." with 0 Failed (1394/1394 Passed); root +line-rate 0.4300204268507431, root branch-rate 0.24095967959333; KaStringAsync.cs covered/total +65/65; per-line hits projection holds 65 rows, no repeated line number; raw output confirmed +outside the repository root and deleted; evidence/qa-gates/ directory listing contains no .xml +entry. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md new file mode 100644 index 000000000..3e2a8225e --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md @@ -0,0 +1,20 @@ +# CSharpier Check (P5-T2) + +- Timestamp: 2026-09-13T01-45 +- Command: tool run csharpier check . (run from repository root, + read-only, repository-wide) +- EXIT_CODE: 0 + +## Verbatim output + +``` +Checked 1624 files in 5552ms. +``` + +## Output Summary + +Exit code 0; only the "Checked N files in T ms." summary line appears, with no per-file line +ahead of it — 0 files needing formatting. Matches the recorded repo-wide clean shape ("Checked +1517 files in 6574ms.", 0 needing formatting) at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/csharpier-check.2026-08-22T09-53.md; +the higher file count reflects the larger repository tree at this later point in time. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md new file mode 100644 index 000000000..23ec5b309 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md @@ -0,0 +1,28 @@ +# CSharpier Format (P5-T1) + +- Timestamp: 2026-09-13T01-40 +- Command: tool run csharpier format + QuickFiler/Controllers/KaStringAsync.cs + QuickFiler.Test/Controllers/KaStringAsyncTests.cs (run from repository root) +- EXIT_CODE: 0 + +## Verbatim output + +``` +Formatted 2 files in 2558ms. +``` + +## SHA-256 hashes (before / after) + +| File | SHA-256 before | SHA-256 after | +|---|---|---| +| QuickFiler/Controllers/KaStringAsync.cs | B404554D23D3C13F215BE28A1A782086AB19C14D9978F87BF46A3CB87052801C | B404554D23D3C13F215BE28A1A782086AB19C14D9978F87BF46A3CB87052801C | +| QuickFiler.Test/Controllers/KaStringAsyncTests.cs | E5083D897D448343D2F977C0EFBC486E146B406EB0EF190AC7ED7DD81814D4EB | E5083D897D448343D2F977C0EFBC486E146B406EB0EF190AC7ED7DD81814D4EB | + +## Output Summary + +Exit code 0; processed count 2 files ("Formatted 2 files in 2558ms."); measured rewrite count +0 (both files' SHA-256 hashes are identical before and after), distinct from the processed +count, per the same processed-versus-rewritten distinction recorded at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/csharpier-format.2026-08-22T09-52.md. +Measured rewrite count is 0, so P5-T15 does not restart this phase from P5-T1. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/final-qc-pass-attestation.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/final-qc-pass-attestation.md new file mode 100644 index 000000000..03e094075 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/final-qc-pass-attestation.md @@ -0,0 +1,90 @@ +# Final QC Pass Attestation (P5-T15) + +- Timestamp: 2026-09-13T02-30 + +## Per-task recorded results, P5-T1 through P5-T7 + +| Task | Gate | EXIT_CODE | Measured rewrite count (P5-T1 only) | +|---|---|---|---| +| P5-T1 | CSharpier format (scoped) | 0 | 0 | +| P5-T2 | CSharpier check (repo-wide) | 0 | n/a | +| P5-T3 | Analyzer Rebuild | 0 | n/a | +| P5-T4 | Nullable Rebuild | 0 | n/a | +| P5-T5 | Coverage-instrumented capture | 0 | n/a | +| P5-T6 | KbdActionsTests re-run | 0 | n/a | +| P5-T7 | Coverage delta computation | n/a (artifact-derived computation; no command exit code) | n/a | + +All recorded exit codes are 0; P5-T1's measured SHA-256 rewrite count is 0. No restart of +Phase 5 from P5-T1 was triggered. + +## Anchored name-status diff (origin/main) + +Command: git diff --name-status origin/main + +``` +M QuickFiler.Test/Controllers/KaStringAsyncTests.cs +M QuickFiler/Controllers/KaStringAsync.cs +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md +A docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md +``` + +The feature folder's issue.md, spec.md, plan file and research record report as Added relative +to origin/main because the worktree HEAD carries a pre-execution documentation commit that +added all four on top of that ref; these four paths are in scope for this check and are not a +reason to restart the phase, per this task's own text. + +## Companion git-status porcelain listing + +Command: git status --porcelain + +``` + M docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md + M docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md +?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md +?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md +?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md +?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md +``` + +The `M` entries are this feature's own plan file and spec.md (task check-offs and AC +check-offs, both entries in spec.md's Write Set). The four untracked paths are this phase's own +evidence artifacts, all inside this feature's evidence subtree. + +## Scope confirmation + +Every path reported by the anchored name-status diff falls within one of: (a) the two +production/test files QuickFiler/Controllers/KaStringAsync.cs and +QuickFiler.Test/Controllers/KaStringAsyncTests.cs; (b) this feature folder's issue.md, spec.md, +plan file, research record, and evidence subtree, all Write Set entries. No path falls outside +that scope. The companion porcelain listing reports no untracked path outside that same scope. +No agent-memory path appears in either listing at this point in execution. + +## Toolchain loop attestation + +The format-then-lint-then-type-check-then-test loop (P5-T1 CSharpier format, P5-T2 CSharpier +check, P5-T3 analyzer Rebuild, P5-T4 nullable Rebuild, P5-T5/P5-T6/P5-T7 test-and-coverage +gates) completed as one uninterrupted pass: every recorded exit code is 0, P5-T1's measured +rewrite count is 0, and no step required a restart from P5-T1. + +## Output Summary + +All of P5-T1 through P5-T7 recorded exit code 0 (P5-T1's measured rewrite count 0); the +anchored name-status diff and companion porcelain listing both fall entirely within the stated +scope; the toolchain loop completed as one uninterrupted pass with no restart. Final QC PASS. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md new file mode 100644 index 000000000..93b41845f --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md @@ -0,0 +1,29 @@ +# KbdActionsTests Post-Change (P5-T6) + +- Timestamp: 2026-09-13T02-10 +- Command: QuickFiler.Test\bin\Debug\QuickFiler.Test.dll + /InIsolation /Settings:scripts\vscode\TaskMaster.cli.runsettings + "/TestCaseFilter:FullyQualifiedName~KbdActionsTests" +- EXIT_CODE: 0 + +## Verbatim result + +``` +Test Run Successful. +Total tests: 4 + Passed: 4 + Total time: 1.2500 Seconds +``` + +## Comparison against P0-T9 baseline + +| | Baseline (P0-T9) | Post-change (P5-T6) | Delta | +|---|---|---|---| +| Passed | 4 | 4 | 0 | +| Failed | 0 | 0 | 0 | + +## Output Summary + +Exit code 0; verdict "Test Run Successful."; Passed count identical to the P0-T9 baseline (4), +0 Failed, delta 0. This is the AC5 evidence, obtained solely from the test-run outcome and +never from a diff of the pinned KbdActionsTests file. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md new file mode 100644 index 000000000..bb7ce340c --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md @@ -0,0 +1,27 @@ +# MSBuild Analyzer Rebuild (Post-Change, P5-T3) + +- Timestamp: 2026-09-13T01-50 +- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug + "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true + /flp:logfile=;verbosity=detailed +- EXIT_CODE: 0 + +## Verdict line + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +## Log-line counts + +- "Skipping target ... CoreCompile" occurrences: 0 +- "CoreCompile:" occurrences: 192 (non-vacuous rebuild) + +## Output Summary + +Exit code 0; verdict "Build succeeded."; "Skipping target CoreCompile" count of exactly 0; +"CoreCompile:" count of 192 (>= 9, non-vacuous); recorded warning count is 0, which is no +greater than the P0-T6 baseline ceiling of 0. Matches the recorded shape at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-analyzers.2026-08-22T09-21.md. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md new file mode 100644 index 000000000..6027f8339 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md @@ -0,0 +1,27 @@ +# MSBuild Nullable Rebuild (Post-Change, P5-T4) + +- Timestamp: 2026-09-13T01-55 +- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug + "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true + /flp:logfile=;verbosity=detailed +- EXIT_CODE: 0 + +## Verdict line + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +## Log-line count + +- "Skipping target ... CoreCompile" occurrences: 0 +- "CoreCompile:" occurrences: 167 (non-vacuous rebuild) + +## Output Summary + +Exit code 0; verdict "Build succeeded."; "Skipping target CoreCompile" count of exactly 0. +Matches the recorded shape at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-nullable.2026-08-22T09-23.md. +No nullable opt-in property was passed. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md new file mode 100644 index 000000000..8425cbd50 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md @@ -0,0 +1,33 @@ +# Green After Fix (P4-T1) + +- Timestamp: 2026-09-13T01-30 +- Command (build): MSBuild.exe (VS18) TaskMaster.sln /t:Build /m /p:Configuration=Debug + "/p:Platform=Any CPU" +- Command (test): + QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /InIsolation + "/TestCaseFilter:FullyQualifiedName~KaStringAsyncTests" +- EXIT_CODE: 0 + +## Verbatim result + +``` +Test Run Successful. +Total tests: 13 + Passed: 13 + Total time: 1.5036 Seconds +``` + +All 13 tests in KaStringAsyncTests passed, individually including: +- KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter (new AC3 test): + Passed +- KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue (reworded prefix-case + test, AC4): Passed + +## Output Summary + +Build (plain incremental) exit 0, compiling the P2-T1 production fix and the P3-T1/P3-T2 +prose rewords into both the QuickFiler and QuickFiler.Test output assemblies. Test run exit +code 0; verdict "Test Run Successful."; 0 Failed among all 13 tests in KaStringAsyncTests; both +the new regression method and the reworded prefix-case test are individually reported Passed — +matching the passing-run shape recorded at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/vstest-baseline.2026-08-22T09-30.md. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md new file mode 100644 index 000000000..350e1692d --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md @@ -0,0 +1,37 @@ +# Red Before Fix (P1-T2) [expect-fail] + +- Timestamp: 2026-09-13T01-15 +- Command (build): MSBuild.exe (VS18) TaskMaster.sln /t:Build /m /p:Configuration=Debug + "/p:Platform=Any CPU" +- Command (test): + QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /InIsolation + "/TestCaseFilter:FullyQualifiedName=QuickFiler.Controllers.Tests.KaStringAsyncTests.KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter" +- EXIT_CODE: 1 +- ExpectedExitCode: 1 + +## Verbatim failure detail + +``` +Failed KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter [142 ms] +Error Message: + Expected updateArg to be "1" because Update receives the last character of the matched span +(Key.IndexOf("1", StringComparison.Ordinal) + other.Length - 1 = 1), not the pre-fix +prefix-only offset that yielded "0", but "0" differs near "0" (index 0). +``` + +``` +Total tests: 1 + Failed: 1 +Test Run Failed. + Total time: 1.2507 Seconds +``` + +## Output Summary + +Build (plain incremental) succeeded, exit 0, compiling the P1-T1 test into the QuickFiler.Test +build output assembly. The scoped vstest run against the new test method, run against +unmodified production code (Phase 2 has not yet run), observed EXIT_CODE 1, matching the +declared ExpectedExitCode of 1. Verdict "Test Run Failed."; exactly 1 test ran and it Failed; +the captured assertion shows an observed value of "0" against an expected "1" — the same +failing-run shape (exit code 1, ExpectedExitCode 1, "Test Run Failed.") recorded at +docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/regression-testing/red-before-fix.2026-08-22T09-38.md. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md new file mode 100644 index 000000000..2b70f6838 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md @@ -0,0 +1,83 @@ +# KaStringAsync.KeyEquals branch 1 computes a prefix-only Substring offset under a Contains guard + +- Issue: #583 +- Type: bug +- Work Mode: full-bug +- Severity: Low (latent) +- Owner: drmoisan +- Last Updated: 2026-09-12T10-25 +- Source: https://github.com/drmoisan/TaskMaster/issues/583 + +## Summary + +Branch 1 of `KaStringAsync.KeyEquals` guards on a substring test but computes its `Update` +argument with prefix arithmetic. The two are only consistent when `other` is a prefix of `Key`. + +In `QuickFiler/Controllers/KaStringAsync.cs`: + +- The branch-1 guard is `Key.Contains(other)` - a substring test. +- Its body computes `Update(Key.Substring(other.Length - 1, 1))`, an offset that is only + meaningful when `other` is a prefix of `Key`. + +For `Key = "abc"` and `other = "b"`, `Contains` is `true` and the expression yields `"a"`, which +is neither the matched character nor the character following it. + +## Reachability + +Reachable in principle whenever the registered digit width is 2. `GenerateStringKbdAction` in +`QuickFiler/Controllers/QfcCollectionController.cs` registers keys `"01"` through `"12"` at that +width; typing `"1"` matches `"01"` at index 1, as a substring rather than as a prefix. + +It has no observable effect today, because `Update` is `null` on every `KaStringAsync` instance +production creates: + +- `QfcCollectionController.cs` passes `null` for both `update` and `toggleControl`. +- `KbdActions.Add(string, TKey, VDelegate)` builds its element with the parameterless + constructor, which assigns neither callback. + +So every `Update is not null` guard in `KeyEquals` evaluates `false` on every production +evaluation. This is a latent defect, not a live one. + +## Maintainer Decision (recorded 2026-09-11) + +Resolution: Option 2 - keep the `Contains` guard and correct the offset arithmetic. + +Branch 1 of `KaStringAsync.KeyEquals` keeps its substring test. The `Update` argument is computed +from the match position (`Key.IndexOf(other)`) so that `Update` receives the last matched +character for every matching row, not only for prefix matches. For the two-digit registration +`"01"` through `"12"`, typing `1` continues to match `01`, `10`, `11`, and `12`; the `01` row now +yields `1` instead of `0`. + +Rationale: this is the only option with no user-visible change to keyboard filtering, and +`Update` is null on every production instance today, so the correction carries no runtime risk. + +Binding constraints on the implementation: + +- Do not substitute `StartsWith` for `Contains`. +- Do not change the branch-1 guard. +- The pinned test FilterKeys_WhenDistinctStoredKeysCoexist_PreservesKeyboardMatchingSemantics in + the QuickFiler.Test KbdActionsTests file must continue to pass unchanged, and that file must + not be edited. + +## Regression Case + +`Key` equal to `"01"`, `other` equal to `"1"`, with a non-null `Update`, must yield `"1"`. +Today it yields `"0"`. + +## Acceptance Criteria + +- [ ] AC1: The recorded decision (corrected offset arithmetic under the retained `Contains` + guard) is reflected in the implementation; the branch-1 guard text is unchanged and no + `StartsWith` call is introduced in `QuickFiler/Controllers/KaStringAsync.cs`. +- [ ] AC2: `KaStringAsync.KeyEquals` branch 1 derives its `Update` argument from the match + position via `Key.IndexOf(other)`, so the character passed to `Update` is the last character + of the matched span for a non-prefix match as well as for a prefix match. +- [ ] AC3: A regression test in `QuickFiler.Test/Controllers/KaStringAsyncTests.cs` covers the + two-digit-width non-prefix case (`Key` equal to `"01"`, `other` equal to `"1"`) with a non-null + `Update` and asserts the received argument is `"1"`. +- [ ] AC4: The pre-existing prefix-case behaviour is preserved: for `Key` equal to `"abc"` and + `other` equal to `"ab"`, `Update` still receives `"b"`. +- [ ] AC5: The pinned keyboard-matching test in the QuickFiler.Test KbdActionsTests file passes + unchanged, and that file is not modified by this change. +- [ ] AC6: The full C# toolchain passes in order: CSharpier check, the analyzer rebuild, the + nullable rebuild, and the MSTest run. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md new file mode 100644 index 000000000..7d66c4a9e --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md @@ -0,0 +1,543 @@ +# kastringasync-keyequals-contains-offset (Plan) + +- **Issue:** #583 +- **Parent (optional):** none +- **Owner:** drmoisan +- **Last Updated:** 2026-09-12T10-25 +- **Status:** Draft +- **Version:** 1.0 +- **Work Mode:** full-bug (per issue.md). Acceptance-criteria source is spec.md's Acceptance Criteria + section only, per the acceptance-criteria-tracking skill's full-bug rule. user-story.md is + intentionally absent; its absence is not a defect for this work mode. + +## Task-count ledger (mechanically counted, per phase) + +| Phase | Tasks | +|---|---| +| 0 | 9 (P0-T1..P0-T9) | +| 1 | 2 (P1-T1..P1-T2) | +| 2 | 1 (P2-T1) | +| 3 | 2 (P3-T1..P3-T2) | +| 4 | 1 (P4-T1) | +| 5 | 15 (P5-T1..P5-T15) | +| **Total** | **30** | + +## Write-set discipline + +An automated blast-radius extractor harvests every whitespace-free backticked token from this +plan and treats it as a claim that the diff writes that path, with no notion of polarity. To keep +this plan's backticked write claims from exceeding spec.md's Write Set, this plan reserves +backtick spans exclusively for the following repository-relative paths, all of which are entries +in that Write Set: issue.md, spec.md, this plan file, the research artifact, the two +production/test source files QuickFiler/Controllers/KaStringAsync.cs and +QuickFiler.Test/Controllers/KaStringAsyncTests.cs (written with their full directory-qualified +paths, never as a bare filename), and the four evidence subfolders under this feature's evidence +directory. + +Every other command, flag, tool name, build output, settings file, identifier, field label, and +context path in this plan is written as plain text with no backticks, including command-line +switches (for example the vstest TestCaseFilter and InIsolation switches), MSBuild target and +property names, the git ref used to anchor diffs, bare source-file names with no directory +qualifier, and every path cited only for context. This applies in particular to: coverage.config, +dotnet-tools.json, the .dotnet-sdk per-worktree SDK junction, the packages directory, TaskMaster.sln, +scripts/vscode/Install-RepoDotNetSdk.ps1, scripts/vscode/TaskMaster.cli.runsettings, the +QuickFiler.Test build output assembly, the collection controller QfcCollectionController.cs, the +keyboard dispatch handler KeyboardHandler.cs, the QuickFiler.Test Controllers KbdActions test +file, and every path cited under the archived feature folder for issue #445 +(docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445) as the source +of recorded gate output. None of these is created, modified, or deleted by this change. This +change's own source and documentation writes are the backtick-marked Write Set paths and no +others. The agent-memory tree is the one further path this branch carries: the agents executing +this plan maintain it as tracked session bookkeeping, it is no part of this change's footprint, +and P5-T15 admits it to that task's scope list on that basis. + +The transient raw coverage output produced by P0-T8 and P5-T5 is likewise plain text here and is +never a write claim against this repository. Each of those tasks directs dotnet-coverage to write +its Cobertura-format file into a session-specific folder beneath the operating system's per-user +temporary directory, resolved at run time from the runtime's temporary-path lookup and lying +outside the repository root; the file is read for its figures and deleted before the task that +produced it completes. This change creates no raw coverage XML anywhere inside the repository, and +no raw coverage document and no test-results file is added to git. Per the projection-only +evidence decision recorded on issue #671 on 2026-09-11, the markdown artifacts named in P0-T8 and +P5-T5 are the evidence of record. + +## Pinned file (read-only, never edited by this plan) + +The QuickFiler.Test Controllers KbdActions test file — the file housing the test method named +for preserving keyboard-matching semantics across distinct stored keys — is pinned per the +maintainer decision in issue.md. No task in this plan edits it. Its unaffected status is asserted +only through test-run outcomes (P0-T9 baseline, P5-T6 post-change), never through a diff of that +file. + +## Command Reference (applies to every task below that names one of these commands) + +- **pwsh invocation.** Every command in this plan that is not a bare git invocation is run through + pwsh -NoProfile -Command, with the outer quoting as single quotes around the whole payload, + because the calling shell expands a dollar sign inside double quotes and would corrupt any + PowerShell-side variable reference in the payload. +- **dotnet.exe resolution.** Resolve the pinned .NET SDK via the per-worktree .dotnet-sdk junction + (running its dotnet.exe with the version switch) first; if that path does not resolve, run + scripts/vscode/Install-RepoDotNetSdk.ps1 as the fallback provisioner (P0-T2). Every later dotnet + invocation in this plan uses the resolved absolute path, referred to below as the resolved + dotnet executable. +- **CSharpier invocation.** CSharpier is invoked only as the resolved dotnet executable's "tool run + csharpier format" or "tool run csharpier check" subcommand, so the dotnet-tools.json + manifest-pinned version 1.2.6 is used, never a global install. CSharpier 1.2.6 requires an + explicit subcommand; a bare csharpier invocation with no subcommand does not run. +- **MSBuild / vstest resolution.** Neither msbuild nor vstest.console.exe is on PATH in this + environment. Resolve each via vswhere.exe at the standard Visual Studio Installer path, using the + latest-version, all-products, find-by-relative-tool-path form, and invoke the resolved absolute + path, referred to below as the resolved vstest executable. +- **Rebuild, never a plain build, for the analyzer and nullable gates.** MSBuild's incremental + up-to-date check does not invalidate on a command-line property change, so a warm plain-build + invocation would return exit 0 with compilation skipped on every project and run no analyzers, + making the gate incapable of failing. Every analyzer-rebuild and nullable-rebuild task in this + plan therefore issues the solution's Rebuild target and records a non-vacuity proof (a zero count + of compile-skip log lines in a detailed file log). A plain incremental build target is used only + in P1-T2 and P4-T1: at P1-T2 its purpose is solely to compile the P1-T1 test-file edit into the + QuickFiler.Test build output assembly; at P4-T1 its purpose is to compile the P2-T1 production + fix and the P3-T1/P3-T2 test-file rewords into both the QuickFiler and QuickFiler.Test build + output assemblies. Neither invocation gates on analyzer or nullable behavior. +- **No solution-wide nullable opt-in property anywhere.** No project in this repository carries a + Nullable element; that MSBuild property is a solution-wide opt-in that CI deliberately omits. No + task in this plan adds it. +- **git diff anchoring.** Every git diff in this plan is anchored to the origin main ref, this + worktree's base, never left unanchored and never pinned to a literal commit SHA. The worktree HEAD + is not identical to that ref: it carries one pre-execution documentation commit that added this + feature folder's issue.md, spec.md, plan file and research record, so an unscoped anchored diff + reports those four paths in addition to whatever this plan's tasks change. P5-T7's anchored diff is + scoped to QuickFiler/Controllers/KaStringAsync.cs, which that commit did not touch, so its added + line set is unaffected; P5-T15's scope list names the four documentation paths explicitly. + +### Phase 0 — Context, Policy Reads, Toolchain Bootstrap, and Baselines + +- [x] [P0-T1] Read, in order, CLAUDE.md, .claude/rules/general-code-change.md, + .claude/rules/general-unit-test.md, .claude/rules/quality-tiers.md, .claude/rules/tonality.md, + and .claude/rules/csharp.md, plus + docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md, + docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md, and + docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md + end to end, then author + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md` + with a Timestamp field, a Policy Order field (the six-file order above), and the explicit list + of every file read, including the two source files QuickFiler/Controllers/KaStringAsync.cs and + QuickFiler.Test/Controllers/KaStringAsyncTests.cs (cited here as plain context, not as write + targets of this task). + Acceptance: the artifact exists and contains all three required fields with the ordered file + list; no policy file is modified. + +- [x] [P0-T2] Resolve the pinned .NET SDK for this worktree (attempt the .dotnet-sdk junction's + dotnet.exe with the version switch; if that path does not resolve, run + scripts/vscode/Install-RepoDotNetSdk.ps1 as the fallback provisioner), then run the resolved + dotnet executable's tool-restore command from the repository root to restore the CSharpier 1.2.6 + tool pinned by dotnet-tools.json, recording both steps in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md`. + Acceptance: an observed exit code of 0 for the tool-restore invocation, with output naming + csharpier version 1.2.6, matching the recorded success shape ("Tool 'csharpier' (version + '1.2.6') was restored... Restore was successful.") at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/dotnet-tool-restore.2026-08-22T09-18.md. + +- [x] [P0-T3] Run a solution-wide NuGet restore of TaskMaster.sln from the repository root to + populate this worktree's packages directory, then verify the restored package-directory count + is at least 100, recording both steps in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md`. + Acceptance: an observed exit code of 0 for the restore command and a recorded package-directory + count of at least 100, consistent in kind with the recorded precedent (265 directories, floor + 150) at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/nuget-restore.2026-08-22T09-18.md. + +- [x] [P0-T4] Probe global-tool availability of the dotnet-coverage tool (a Get-Command lookup + followed by its version switch); if absent, install it as a global dotnet tool via the resolved + dotnet executable. Record the result in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md`. + Acceptance: the artifact records the tool as present, with a resolved path, a version string, and + an observed exit code of 0, matching the recorded shape at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/coverage-tool-probe.2026-08-22T09-32.md. + +- [x] [P0-T5] Run the resolved dotnet executable's CSharpier check subcommand against the whole + repository root (read-only) and record a Timestamp field, a Command field, an EXIT_CODE field, + and an Output Summary field stating the observed files-checked count and files-needing-formatting + count in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md`. + Acceptance: an observed exit code of 0 and a summary line of the form "Checked N files in T ms." + with 0 files needing formatting, matching the recorded clean-tree shape ("Checked 1517 files in + 6621ms.", exit code 0) at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/csharpier-check.2026-08-22T09-19.md. + +- [x] [P0-T6] Run the solution's analyzer Rebuild (Configuration Debug, Platform Any CPU, the + EnableNETAnalyzers and EnforceCodeStyleInBuild properties both true) with a detailed file logger, + then count the "Skipping target CoreCompile" and "CoreCompile:" log-line occurrences, and count + the total warning lines. Record a Timestamp field, a Command field, an EXIT_CODE field, and an + Output Summary field stating the observed warning count in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md`. + Acceptance: an observed exit code of 0, a verdict line reading "Build succeeded.", a + "Skipping target CoreCompile" count of exactly 0, a "CoreCompile:" count of at least 9 + (proving the rebuild was not vacuous), and a recorded numeric warning count, which is the + ceiling P5-T3 compares against, matching the recorded shape (0 skip / 96 CoreCompile / 5 + pre-existing System.Reactive packages.config warnings / 0 errors) at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-analyzers.2026-08-22T09-21.md. + +- [x] [P0-T7] Run the solution's nullable Rebuild (Configuration Debug, Platform Any CPU, the + TreatWarningsAsErrors property true, no nullable opt-in property) with a detailed file logger, + then count the "Skipping target CoreCompile" log-line occurrences. Record a Timestamp field, a + Command field, an EXIT_CODE field, and an Output Summary field in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md`. + Acceptance: an observed exit code of 0, a verdict line reading "Build succeeded.", and a + "Skipping target CoreCompile" count of exactly 0, matching the recorded shape (0 skip / 130 + CoreCompile / 5 pre-existing warnings / 0 errors) at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-nullable.2026-08-22T09-23.md. + +- [x] [P0-T8] Run a coverage-instrumented test capture of the whole QuickFiler.Test.dll assembly, + using dotnet-coverage collect wrapping the resolved vstest executable against that assembly with + the TaskMaster CLI runsettings file, InIsolation, and the LiveOutlook-category exclusion filter. + The coverage tool still writes its Cobertura-format output, but to a transient file in a + session-specific folder beneath the operating system's per-user temporary directory, resolved at + run time from the runtime's temporary-path lookup, never to any path inside this repository. + That raw file is read for its figures within this task and then deleted at the end of this task; + it is never added to git, and no raw coverage document is written under this feature's evidence + directory. From the raw document, read the root line-rate and branch-rate attributes; aggregate + every class element whose filename ends with the KaStringAsync source file name (deduplicated by + line number, maximum hits) into a covered/total count for that file; and derive from the same + aggregation a per-line hits projection for that file, one row per executable line number paired + with its maximum hit count across every matching class element, deduplicated by line number. + Record the numeric root line-rate, the numeric root branch-rate, the covered/total count, and the + per-line hits projection (under a section heading reading Per-line hits projection) in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md`, + together with: the raw-output location expressed as its subfolder and file name relative to the + temporary directory root (no absolute host path); the run-time result of a prefix comparison + showing that the resolved temporary directory's full path does not begin with the repository + root's full path, recorded as that comparison's boolean verdict alone with neither operand path + reproduced; the post-deletion existence check of the raw file; and a directory listing of + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/` + taken after the deletion, recorded as entry names only with no containing-directory header line. + None of those four recorded observations reproduces an absolute path: the raw-output location is + recorded relative to the temporary directory root, the prefix comparison as a labelled boolean + with neither operand reproduced, the existence check as a boolean, and the directory listing as + entry names only. That constraint governs those four observations only. The artifact's mandatory + Command field records the exact command, which names the resolved absolute executable paths per + the Command Reference above, and lies outside the constraint's scope; the archived precedent this + task is compared against records its command in the same form. + Acceptance: an observed exit code of 0; the inner test run reports "Test Run Successful." with 0 + Failed; the Output Summary field carries the numeric line-rate, branch-rate, and the + KaStringAsync file's covered/total counts (not a placeholder), using the same per-file + aggregation method recorded at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/coverage-baseline.2026-08-22T09-34.md; + the artifact's per-line hits projection section holds at least one row and repeats no line + number; the recorded directory listing of the baseline evidence subfolder contains no entry + whose name ends with .xml; the recorded prefix comparison shows the raw-output location lies + outside this repository's root; the recorded post-deletion existence check reports the raw + file absent; and none of the four observations above reproduces an absolute path — the + raw-output location appearing relative to the temporary directory root, the prefix comparison as + a labelled boolean with neither operand path, the existence check as a boolean, and the directory + listing as entry names only. + +- [x] [P0-T9] Run a scoped, non-instrumented vstest filter selecting every test whose fully + qualified name contains the pinned KbdActions test class name, against the QuickFiler.Test build + output assembly, with InIsolation and the TaskMaster CLI runsettings file, to record the pinned + KbdActions test file's baseline Passed/Failed count without ever diffing that file. Record a + Timestamp field, a Command field, an EXIT_CODE field, and an Output Summary field in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md`. + Acceptance: an observed exit code of 0, a verdict line reading "Test Run Successful.", and a + recorded Passed count with 0 Failed for that class, in the same per-class reporting shape (class + name, with total, passed, failed and exit columns) recorded at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/vstest-baseline.2026-08-22T09-30.md; + that archived record's own count, three of three on 2026-08-22, predates one test method since + added to the pinned KbdActions test file, so this task's recorded count is expected to differ + from that archived figure and is not compared against it. + +### Phase 1 — Regression Test First (Red Before Fix) + +- [x] [P1-T1] Add a new test method named + KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter to + `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`, using the file's existing NewKa factory + helper, MSTest attributes, a FluentAssertions assertion with a because-style string, and + Arrange/Act/Assert section comments preceded by an Intent comment block naming issue #583 and + both the pre-fix ("0") and post-fix ("1") results: construct with Key equal to "01", a non-null + Update callback capturing its argument, Activated set to true, call KeyEquals with the argument + "1", and assert the captured argument equals "1". + Acceptance: the method exists in the file with exactly this Key/other/Activated arrangement and + an assertion of the literal "1"; the file's pre-existing test methods are otherwise unchanged. + +- [x] [P1-T2] [expect-fail] Build TaskMaster.sln with a plain incremental build (Configuration Debug, Platform + Any CPU) to compile the P1-T1 test into the QuickFiler.Test build output assembly, then run the + resolved vstest executable against that assembly with InIsolation and a test-case filter + selecting only the new test method by its exact fully qualified name, against unmodified + production code (Phase 2 has not yet run). Record the failing run, with an ExpectedExitCode + field declared as 1 so the evidence collector normalizes the row rather than reading a genuine + failure, in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md`. + Acceptance: the observed EXIT_CODE is 1, matching the declared ExpectedExitCode of 1; the verdict + line reads "Test Run Failed."; exactly 1 test ran and it Failed, with the captured assertion + showing an observed value of "0" against an expected "1" — the same failing-run shape (exit code + 1, ExpectedExitCode 1, "Test Run Failed.") recorded at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/regression-testing/red-before-fix.2026-08-22T09-38.md. + +### Phase 2 — Minimal Production Fix + +- [x] [P2-T1] In `QuickFiler/Controllers/KaStringAsync.cs`, replace the branch-1 Update argument + expression, currently reading Key.Substring(other.Length - 1, 1) at line 128, with + Key.Substring(Key.IndexOf(other, StringComparison.Ordinal) + other.Length - 1, 1), leaving the + Key.Contains(other) guard at line 125 and every other line of the method unchanged, and + introducing no StartsWith call anywhere in the file. + Acceptance: the file contains the new expression at that call site; the literal text + "Substring(other.Length - 1, 1)" no longer appears anywhere in the file; the guard text + "Key.Contains(other)" is present and unchanged; the file contains no occurrence of the text + "StartsWith(". + +### Phase 3 — Prose Rewords + +- [x] [P3-T1] In `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`, reword the because-string in + the test named for a contains-match while activated that invokes Update and returns true, from + "Update receives Key.Substring(other.Length - 1, 1) => index 1 => \"b\"" to "Update receives the + last character of the matched span (Key.IndexOf(\"ab\", StringComparison.Ordinal) + other.Length + - 1 = 1) => \"b\"", without changing the asserted value "b". + Acceptance: the old because-string no longer appears in the file; the new because-string appears + verbatim; the test's assertion of the value "b" is unchanged. + +- [x] [P3-T2] In `QuickFiler/Controllers/KaStringAsync.cs`, reword the doc-comment sentence in the + "Argument contract" paragraph, currently at lines 80-82, from "The guard clause at the top of + this method rejects both fail-fast, so branch 1's substring offset expression is never evaluated + with a negative start index." to "The guard clause at the top of this method rejects both + fail-fast, so branch 1's derived offset (Key.IndexOf(other) plus the matched length) is never + evaluated with a negative start index: IndexOf is non-negative because branch 1 only runs when + Contains already matched, and other.Length is at least 1 because of the guard above." + Acceptance: the old sentence no longer appears in the file; the new sentence appears verbatim + inside the XML doc comment; no other sentence in the doc comment is changed. + +### Phase 4 — Green-After-Fix Verification + +- [x] [P4-T1] Build TaskMaster.sln with a plain incremental build (Configuration Debug, Platform + Any CPU) to compile the P2-T1 fix and the P3-T1/P3-T2 rewords into the QuickFiler.Test build + output assembly and QuickFiler's own output assembly, then run the resolved vstest executable + against the QuickFiler.Test build output assembly with InIsolation and a test-case filter + selecting every test whose fully qualified name contains the KaStringAsyncTests class name. + Record the passing run in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md`. + Acceptance: an observed exit code of 0; the verdict line reads "Test Run Successful."; 0 Failed + among every test in that class; the new method + KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter and the test named + for a contains-match while activated that invokes Update and returns true are both individually + reported Passed — the same passing-run shape ("Test Run Successful.", 0 Failed, exit code 0) + recorded at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/vstest-baseline.2026-08-22T09-30.md. + +### Phase 5 — Final Quality-Assurance Loop + +- [x] [P5-T1] Run the resolved dotnet executable's CSharpier format subcommand from the repository + root, scoped only to the two files this change edits (QuickFiler/Controllers/KaStringAsync.cs + and `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`), then take a SHA-256 hash of each file + immediately before and immediately after the invocation to measure the actual rewrite count + (distinct from CSharpier's own processed-file count). Record a Timestamp field, a Command field, + an EXIT_CODE field, and an Output Summary field (stating both the processed count and the + measured rewrite count) in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md`. + Acceptance: an observed exit code of 0; the summary states a processed count of 2 files, distinct + from the SHA-256-measured rewrite count, per the same processed-versus-rewritten distinction + recorded at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/csharpier-format.2026-08-22T09-52.md; + if the measured rewrite count is greater than 0, P5-T15 restarts this phase from P5-T1. + +- [x] [P5-T2] Run the resolved dotnet executable's CSharpier check subcommand from the repository + root (read-only, repository-wide) and record a Timestamp field, a Command field, an EXIT_CODE + field, and an Output Summary field in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md`. + Acceptance: an observed exit code of 0 and a summary reporting 0 files needing formatting (only + the "Checked N files in T ms." summary line appears, with no per-file line ahead of it), matching + the recorded repo-wide clean shape ("Checked 1517 files in 6574ms.", 0 needing formatting) at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/csharpier-check.2026-08-22T09-53.md. + +- [x] [P5-T3] Run the solution's analyzer Rebuild (Configuration Debug, Platform Any CPU, the + EnableNETAnalyzers and EnforceCodeStyleInBuild properties both true) with a detailed file logger, + count the "Skipping target CoreCompile" and "CoreCompile:" log-line occurrences, and record a + Timestamp field, a Command field, an EXIT_CODE field, and an Output Summary field in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md`. + Acceptance: an observed exit code of 0, a verdict of "Build succeeded.", a "Skipping target + CoreCompile" count of exactly 0, a "CoreCompile:" count of at least 9, and a warning count no + greater than the P0-T6 baseline ceiling, matching the recorded shape at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-analyzers.2026-08-22T09-21.md. + +- [x] [P5-T4] Run the solution's nullable Rebuild (Configuration Debug, Platform Any CPU, the + TreatWarningsAsErrors property true, no nullable opt-in property) with a detailed file logger, + count the "Skipping target CoreCompile" log-line occurrences, and record a Timestamp field, a + Command field, an EXIT_CODE field, and an Output Summary field in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md`. + Acceptance: an observed exit code of 0, a verdict of "Build succeeded.", and a "Skipping target + CoreCompile" count of exactly 0, matching the recorded shape at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-nullable.2026-08-22T09-23.md. + +- [x] [P5-T5] Run a coverage-instrumented test capture of the whole QuickFiler.Test.dll assembly, + identical in form to P0-T8. The coverage tool still writes its Cobertura-format output, but to a + transient file in a session-specific folder beneath the operating system's per-user temporary + directory, resolved at run time from the runtime's temporary-path lookup, never to any path + inside this repository. That raw file is read for its figures within this task and then deleted + at the end of this task; it is never added to git, and no raw coverage document is written under + this feature's evidence directory. From the raw document, read the root line-rate and + branch-rate attributes; aggregate every class element whose filename ends with the KaStringAsync + source file name (deduplicated by line number, maximum hits) into a post-change covered/total + count for that file; and derive from the same aggregation the per-line hits projection for that + file in the same form as P0-T8 (one row per executable line number paired with its maximum hit + count across every matching class element, deduplicated by line number), which P5-T7's + changed-line intersection reads. Record the numeric root line-rate, the numeric root + branch-rate, the post-change covered/total count, and the per-line hits projection (under a + section heading reading Per-line hits projection) in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md`, + together with: the raw-output location expressed as its subfolder and file name relative to the + temporary directory root (no absolute host path); the run-time result of a prefix comparison + showing that the resolved temporary directory's full path does not begin with the repository + root's full path, recorded as that comparison's boolean verdict alone with neither operand path + reproduced; the post-deletion existence check of the raw file; and a directory listing of + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/` + taken after the deletion, recorded as entry names only with no containing-directory header line. + None of those four recorded observations reproduces an absolute path: the raw-output location is + recorded relative to the temporary directory root, the prefix comparison as a labelled boolean + with neither operand reproduced, the existence check as a boolean, and the directory listing as + entry names only. That constraint governs those four observations only. The artifact's mandatory + Command field records the exact command, which names the resolved absolute executable paths per + the Command Reference above, and lies outside the constraint's scope; the archived precedent this + task is compared against records its command in the same form. + Acceptance: an observed exit code of 0; the inner test run reports "Test Run Successful." with 0 + Failed; the Output Summary field carries the numeric line-rate, branch-rate, and the + KaStringAsync file's covered/total counts (not a placeholder), using the same per-file + aggregation method recorded at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/coverage-postchange.2026-08-22T10-38.md; + the artifact's per-line hits projection section holds at least one row and repeats no line + number; the recorded directory listing of the qa-gates evidence subfolder contains no entry + whose name ends with .xml; the recorded prefix comparison shows the raw-output location lies + outside this repository's root; the recorded post-deletion existence check reports the raw + file absent; and none of the four observations above reproduces an absolute path — the + raw-output location appearing relative to the temporary directory root, the prefix comparison as + a labelled boolean with neither operand path, the existence check as a boolean, and the directory + listing as entry names only. + +- [x] [P5-T6] Run the same scoped, non-instrumented vstest filter used at P0-T9 (selecting every + test whose fully qualified name contains the pinned KbdActions test class name, against the + QuickFiler.Test build output assembly, with InIsolation and the TaskMaster CLI runsettings file) + after the fix, and compare its Passed count against the P0-T9 baseline, without ever diffing the + pinned file. Record a Timestamp field, a Command field, an EXIT_CODE field, and an Output Summary + field (stating both counts and their delta) in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md`. + Acceptance: an observed exit code of 0, a verdict of "Test Run Successful.", and a Passed count + identical to the P0-T9 baseline with 0 Failed and a delta of 0 — this is the AC5 evidence, + obtained solely from the test-run outcome and never from a diff of the pinned file. + +- [x] [P5-T7] Compute the coverage delta from the two markdown evidence artifacts + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md` + (P0-T8) and + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md` + (P5-T5), reading no raw coverage document (both raw outputs were deleted by the tasks that + produced them): (a) the instrumented-run line-rate/branch-rate delta, from the numeric root + line-rate and branch-rate figures recorded in the two artifacts; (b) the KaStringAsync file's + per-file covered/total before and after, from the covered/total counts recorded in the two + artifacts; and (c) changed-line coverage, by intersecting the added line numbers from a git diff + of QuickFiler/Controllers/KaStringAsync.cs anchored to the origin main ref, using a zero-context + unified diff, against the per-line hits projection recorded in coverage-postchange.md, whose rows + are that file's executable lines only: the denominator of the changed-line percentage is the set + of added line numbers that appear as a row in that projection, and the added line numbers with no + row there are non-executable and are listed separately as excluded from both the numerator and the + denominator. The reworded doc-comment lines from P3-T2 are the expected members of that excluded + set. A continuation line introduced by a formatter wrap is classified by the same row test rather + than assumed to be absent from the projection: the recorded evidence at + docs/features/active/2026-08-26-efc-unguarded-archive-root-read-crashes-ui-thread-638/evidence/qa-gates/p7-t2-coverage-changed-lines.md + shows each continuation line of a wrapped call carrying its own row, so such a line belongs in the + denominator whenever it appears as a row there. Also + compare the per-line hits projection recorded in coverage-baseline.md against the one recorded + in coverage-postchange.md: map each baseline line number to its post-change line number through + the same anchored zero-context diff (a baseline line outside every hunk shifts by the net + added-minus-removed line count of the hunks that precede it; a baseline line inside a removed + hunk range has no post-change counterpart, is excluded from this comparison, and is covered by + (c) through its replacement lines), then pair each mapped line's baseline hits with its + post-change hits. Record all three computations, plus the projection comparison, plus a + companion git-status porcelain listing (to confirm no untracked file escapes the anchored + diff's tracked-file scope), in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md`. + Acceptance: the artifact records numeric baseline coverage, numeric post-change coverage, and a + numeric changed-line coverage percentage over the denominator stated above; that denominator + holds at least one line; every added line of + QuickFiler/Controllers/KaStringAsync.cs that appears as a row in the coverage-postchange.md + projection shows hits of at least 1 there, and the artifact lists the added lines excluded as + non-executable; and no line with hits of at least 1 in the coverage-baseline.md projection has + hits of 0 at its mapped line in the coverage-postchange.md projection, following the changed-line + intersection methodology recorded at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/coverage-delta.2026-08-22T10-40.md + with the per-line hit source being the recorded projections rather than a raw document. + +- [x] [P5-T8] Check off AC1 in + `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria + section ("The recorded maintainer decision ... is reflected in the implementation ...") by + changing its unchecked box to a checked box, citing P2-T1's diff (guard text unchanged, no + StartsWith introduced) as the verifying evidence. + Acceptance: only that one checkbox line changes in the file; no criterion text is altered. + +- [x] [P5-T9] Check off AC2 in + `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria + section ("KaStringAsync's KeyEquals branch-one derives its Update argument from the match + position via Key.IndexOf(other) ...") by changing its unchecked box to a checked box, citing + P2-T1's diff and P4-T1's green run as the verifying evidence. + Acceptance: only that one checkbox line changes in the file; no criterion text is altered. + +- [x] [P5-T10] Check off AC3 in + `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria + section ("A regression test in QuickFiler.Test/Controllers/KaStringAsyncTests.cs covers the + two-digit-width non-prefix case ...") by changing its unchecked box to a checked box, citing + P1-T1 (test added) and P4-T1 (passes post-fix) as the verifying evidence. + Acceptance: only that one checkbox line changes in the file; no criterion text is altered. + +- [x] [P5-T11] Check off AC4 in + `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria + section ("The pre-existing prefix-case behavior is preserved ...") by changing its unchecked box + to a checked box, citing P3-T1's reword (asserted value unchanged) and P4-T1's green run as the + verifying evidence. + Acceptance: only that one checkbox line changes in the file; no criterion text is altered. + +- [x] [P5-T12] Check off AC5 in + `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria + section ("The pinned keyboard-matching test in the QuickFiler.Test KbdActionsTests file passes + unchanged, and that file is not modified ...") by changing its unchecked box to a checked box, + citing P0-T9 and P5-T6's matching Passed counts (0 delta) as the verifying evidence. + Acceptance: only that one checkbox line changes in the file; no criterion text is altered. + +- [x] [P5-T13] Check off AC6 in + `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria + section ("The full C# toolchain passes in order ...") by changing its unchecked box to a checked + box, citing P5-T1 through P5-T7's four gate artifacts as the verifying evidence. + Acceptance: only that one checkbox line changes in the file; no criterion text is altered. + +- [x] [P5-T14] Verify all six acceptance criteria in + `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md` are checked, and + report the AC Status Summary (Source, Total AC items, Checked off, Remaining, Items remaining) + per the acceptance-criteria-tracking skill in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md`. + Acceptance: the artifact reports Total AC items 6, Checked off 6, Remaining 0. + +- [x] [P5-T15] Attest, in + `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/final-qc-pass-attestation.md`, + that P5-T1 through P5-T7 each recorded an exit code of 0 (or, for P5-T1, a measured rewrite count + of 0), by running an anchored name-status git diff against the origin main ref together with a + companion git-status porcelain listing (per the paired-diff convention: the anchored diff + enumerates committed tracked changes, the porcelain listing catches anything uncommitted or + untracked) and confirming every path reported by the anchored name-status diff, which enumerates + tracked changes only, falls only within: the two production/test files + QuickFiler/Controllers/KaStringAsync.cs and `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`; + this feature folder, comprising its issue.md, its spec.md, this plan file, its research record and + its evidence subtree, every one of which is an entry in spec.md's Write Set; and the agent-memory + tree, which the agents executing this plan write as tracked session bookkeeping and which is no + part of this change's source or documentation footprint. The anchored diff is expected to report + this feature folder's issue.md, spec.md, plan file and research record as added relative to the + origin main ref, because the worktree HEAD carries a pre-execution documentation commit that added + all four on top of that ref; those four paths are in scope for this check and are not a reason to + restart the phase. The companion git-status porcelain listing is expected to + report the evidence artifacts this phase creates, which git reports as one collapsed entry per + wholly-untracked directory rather than one line per file; that entry lies within the evidence + subtree named above, and the porcelain listing exists to catch a change the anchored diff + cannot see rather than to be scope-checked as a tracked change. If any of P5-T1 through P5-T7 + recorded a non-zero exit code (excluding a declared ExpectedExitCode normalization), or + P5-T1's measured rewrite count was greater than 0, or the anchored diff shows a tracked file + outside that scope, or the porcelain listing reports an untracked path outside that scope, this + task instructs restarting Phase 5 from P5-T1, per the general-code-change.md restart rule. + Acceptance: the artifact states, for each of P5-T1 through P5-T7, its recorded exit code and, + for P5-T1, its measured rewrite count; lists the paired diff/status output; confirms every + path reported by the anchored diff falls within the stated scope and that the porcelain + listing reports no untracked path outside it; and confirms the + format-then-lint-then-type-check-then-test loop completed as one uninterrupted pass, in the + same attestation shape recorded at + docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/final-qc-pass-attestation.2026-08-22T10-42.md. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md new file mode 100644 index 000000000..fc3668310 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md @@ -0,0 +1,177 @@ +# Research: KaStringAsync.KeyEquals branch-1 offset correction (Issue #583) + +- Timestamp: 2026-09-12T10-35 +- Scope: preparation-only research. No production source edited, no build or test run performed. + +## R1 — Current branch-1 expression and surrounding structure + +File: `QuickFiler/Controllers/KaStringAsync.cs`. + +`KeyEquals(string other)` spans lines 106-145. The guard clauses (null at 110-113, empty at +115-123) run first. The branch-1 block is lines 125-130: + +```csharp +125 if (Key.Contains(other)) +126 { +127 if (Activated && Update is not null) +128 Update(Key.Substring(other.Length - 1, 1)); +129 return true; +130 } +``` + +Branches 2 and 3 follow as `else if (other.Length == 1)` (131-135, `ToggleControl`) and +`else if (other.Length > 1)` (136-142, `Update(Key.Substring(0, 1))` then `ToggleControl`), with +the shared `Activated = false; return false;` trailer at 143-144. Only the branch-1 offset +(line 128) is in scope per the maintainer decision; branch 3's `Key.Substring(0, 1)` (line 139) +is untouched by Option 2 and is a separate, already-correct expression (first character of the +key, unconditional on match position). + +## R2 — Comparison semantics (Contains vs IndexOf) and analyzer exposure + +**Target framework.** `QuickFiler/QuickFiler.csproj` line 13: `v4.8.1` — legacy non-SDK-style .NET Framework 4.8.1 project (packages.config, explicit `` items). + +**Ordinal-vs-culture asymmetry.** `string.Contains(string)` performs an ordinal (culture-insensitive) search in both .NET Framework and .NET; `string.IndexOf(string)` with no `StringComparison` argument performs a culture-sensitive search using the current culture in .NET Framework. This is the exact asymmetry CA1307 ("Specify StringComparison for clarity") and CA1310 ("Specify StringComparison for correctness") exist to flag: CA1310 is the one Microsoft's guidance associates with `string.IndexOf(string)`, because that overload's default behavior is culture-dependent and can differ from an ordinal caller's expectation; CA1307 is the milder, clarity-only counterpart typically associated with methods such as `Contains`/`Equals` whose behavior does not actually vary by culture. + +**Can they disagree for this input domain?** No evidence of disagreement was found or is expected for this domain. The registered keys are two-digit numeral strings (`"01"`–`"12"`, confirmed at `QfcCollectionController.cs:1109-1112`, `key = (i + 1).ToString("00")`) and the probes are the accumulated single/short ASCII-digit filter string built in `KeyboardHandler.cs:180` (`_filterBuilder.Append(char.ToLower((char)e.KeyValue))`). ASCII digit characters (`0`–`9`) have no culture-specific collation, casing, or normalization differences under any standard .NET culture, so an ordinal search and a current-culture search over this restricted alphabet locate the same substring at the same index. This is a characterization from documented framework behavior over the enumerated input alphabet, not a runtime observation (no code was executed for this research). + +**Analyzer configuration search.** `.editorconfig` (repo root) was searched in full for `CA1307`/`CA1310`: no explicit entries exist. The file instead carries a single blanket default at line 27: + +``` +dotnet_analyzer_diagnostic.severity = suggestion +``` + +with the comment (lines 23-26) stating this default exists specifically "so [new analyzer diagnostics] cannot be promoted to errors under the nullable `/p:TreatWarningsAsErrors=true` build." Every explicit override in the file (Meziantou `MA####`, Roslynator `RCS####`, Sonar `S####`, `CRR0029`) is also pinned to `suggestion`; the only diagnostic pinned above suggestion is `MSTEST0032` at `warning` (line 29). No `.globalconfig` file exists anywhere in the repository (glob returned no matches). `QuickFiler.csproj`'s `` items (lines 594-604) are Meziantou, Roslynator, AsyncFixer, BannedApiAnalyzers, and SonarAnalyzer — none of these ship CA1307/CA1310 (those are built-in Roslyn/.NET-analyzer IDs, gated by the project's `EnableNETAnalyzers=true` MSBuild property used in the approved analyzer-rebuild command, not by a NuGet `` reference). + +**Conclusion.** Because `dotnet_analyzer_diagnostic.severity = suggestion` is a blanket, source-agnostic default with no CA1307/CA1310-specific override anywhere in the repository, even if CA1310 fires on `Key.IndexOf(other)` (no `StringComparison`), it would report at `suggestion` severity. Suggestion-severity diagnostics are not warnings and are not promoted to errors by either the analyzer rebuild (`/p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true`, no `TreatWarningsAsErrors`) or the nullable rebuild (`/p:TreatWarningsAsErrors=true`, which only escalates *warnings*). An explicit `StringComparison.Ordinal` argument on `IndexOf` is therefore **not required** for either rebuild to pass. It is **advisable**: it makes the offset derivation's comparison semantics textually consistent with the ordinal `Contains` guard it depends on, and it preempts the (currently dormant) CA1307/CA1310 diagnostics if their severities are ever tightened. Adding `StringComparison.Ordinal` would **not itself introduce a diagnostic** — it is precisely the fix pattern CA1307/CA1310 recommend. + +## R3 — Callers of KeyEquals and producers of a non-null Update + +**`KeyEquals` overloads/implementers** (`IKbdAction.KeyEquals`, `IKbdAction.cs:14`): four sibling implementations exist — `KaChar.cs:42,77`, `KaKey.cs:43,78`, and the one under study, `KaStringAsync.cs:106`. Only the `string`-keyed `KaStringAsync` implementation contains the Contains/Substring branch-1 logic in question. + +**Callers, production (`QuickFiler/Controllers/KbdActions.cs`):** every call to `x.KeyEquals(key)` is inside the generic `KbdActions` class: `ContainsKey` (85), `FilterKeys` (87), `Find` (91), `FindIndex` (109, 116). `KeyboardHandler.cs:181,188` calls `StringActionsAsync.ContainsKey(...)` and `StringActionsAsync.FilterKeys(...)` on the live keyboard-dispatch path (`StringActionsAsync` typed as `KbdActions>`, declared at `IQfcKeyboardHandler.cs:26` and `KeyboardHandler.cs:84`). This is the only production call chain that reaches `KaStringAsync.KeyEquals`. + +**Production construction sites for `KaStringAsync`:** exactly one, `QfcCollectionController.GenerateStringKbdAction` (`QfcCollectionController.cs:1114-1121`): + +```csharp +1114 var stringAsyncAction = new KaStringAsync( +1115 "Collection", +1116 key, +1117 (s) => ChangeByIndexAsync(int.Parse(s) - 1), +1118 //(s) => grp.ItemViewer.LblItemNumber.Text = s, +1119 null, +1120 null +1121 ); +``` + +Both `update` (position 4) and `toggleControl` (position 5) are the literal `null` at 1119-1120; the intended `Update` callback is present only as a comment (line 1118), never wired. The only other production path that could construct a `KaStringAsync` is `KbdActions<...>.Add(string, TKey, VDelegate)` (`KbdActions.cs:126-140`), which builds its element with `new()` — the parameterless constructor (`KaStringAsync.cs:12`) — and assigns only `SourceId`, `Key`, `Delegate` (`KbdActions.cs:135-138`); it never touches `Update`/`ToggleControl`, so both remain their default `null`. No other production call site constructs a `KaStringAsync`. + +**Conclusion:** the issue's claim is confirmed — `Update` is `null` on every `KaStringAsync` instance production code creates, via both the direct constructor call and the generic `Add(string, TKey, VDelegate)` path. Test code is the only place a non-null `Update` is ever supplied (`KaStringAsyncTests.cs`'s `NewKa` helper, line 26, and inline test-local lambdas). + +## R4 — Existing tests constraining branch-1 behaviour + +File: `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`. + +| Test method | Inputs | Asserted value | Stays TRUE under Option 2? | +|---|---|---|---| +| `KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue` (78-97) | `Key="abc"`, `other="ab"`, `Activated=true` | `updateArg == "b"`, with because-string `"Update receives Key.Substring(other.Length - 1, 1) => index 1 => \"b\""` (line 90-92) | **Value stays TRUE** (see arithmetic below). **Because-string becomes stale** — it quotes the exact old expression, which is no longer what the code executes. | +| `KeyEquals_ContainsMatchWhileNotActivated_ReturnsTrueWithoutUpdate` (100-115) | `Key="abc"`, `other="ab"`, `Activated=false` | `updateCalled == false` | Unaffected — gated on `Activated`, never reaches the offset expression. | +| `KeyEquals_SingleCharNonMatchWhileActivated_...` (118-132) | `Key="abc"`, `other="z"` | branch 2 (`ToggleControl`) | Unaffected — branch 2, no offset expression involved. | +| `KeyEquals_MultiCharNonMatchWhileActivated_InvokesUpdateWithFirstCharAndReturnsFalse` (135-153) | `Key="abc"`, `other="zz"` | `updateArg == "a"` (branch 3, `Key.Substring(0,1)`) | Unaffected — branch 3's expression is untouched by Option 2. | +| `KeyEquals_NullDelegatesAreToleratedInNonMatchBranches` (156-167) | `Key="abc"`, `other="zz"`, null delegates | no throw | Unaffected — branch 3. | +| `KeyEquals_MultiCharNonMatchWhileNotActivated_...` (170-188) | `Key="abc"`, `other="zz"`, `Activated=false` | `updates` empty | Unaffected — branch 3, gated on `Activated`. | +| `KeyEquals_LatchSurvivesMatchThenNonMatchTransition_StillResetsToFirstChar` (191-214) — **the matching-then-non-matching probe test** | `Key="abc"`; `KeyEquals("ab")` then `KeyEquals("zz")` | `updates.Should().Equal(new[] { "b", "a" })` | **Stays TRUE.** First call is branch 1 with `other="ab"` (prefix match, arithmetic unchanged — see below); second call is branch 3, untouched. No stale literal in this test's prose. | +| `KeyEquals_EmptyProbe_ThrowsArgumentExceptionNamingOther` (217-256) | `""`, both an inactive and an active+non-null-Update instance | `ArgumentException` named `"other"` | Unaffected — the empty-string guard clause (lines 115-123) runs before any `Contains`/offset code in both the old and new implementation. | +| `KeyEquals_NullProbe_ThrowsArgumentNullExceptionNamingOther` (259-277) | `null` | `ArgumentNullException` named `"other"` | Unaffected — null guard runs first, same in both versions. | + +**Arithmetic — the only test with a stale literal, `KeyEquals_ContainsMatchWhileActivated_...`:** +`Key="abc"`, `other="ab"`. `Key.Contains("ab")` is `true` under both the current and the corrected code (the guard is unchanged). `other` is a prefix of `Key` here (`IndexOf("ab") == 0`). +- Current: `Key.Substring(other.Length - 1, 1)` = `Substring(1, 1)` = `"b"`. +- Corrected: last character of the matched span = `Key.IndexOf(other) + other.Length - 1` = `0 + 2 - 1 = 1`; `Key.Substring(1, 1)` = `"b"`. +Both formulas agree because this probe happens to be a prefix match — this is exactly why AC4 (below) pins the prefix case as unaffected, and exactly why the because-string is stale prose rather than a wrong assertion: the *value* `"b"` is still correct, but the *reason given* (a literal quote of the soon-to-be-replaced expression) will no longer describe the code. Recommended reword: replace the quoted literal with a description, e.g. `"Update receives the last character of the matched span (IndexOf(\"ab\") + other.Length - 1 = 1) => \"b\""`. + +No other test in the file quotes the literal expression `Key.Substring(other.Length - 1, 1)` in an assertion or a because-string (a targeted grep of the file for that exact literal found it only in the production source at line 128, not in any test assertion string; the test's because-string quotes it as descriptive prose, which is the one occurrence requiring rewording). + +## R5 — The pinned test (`KbdActionsTests.cs`) + +File: `QuickFiler.Test/Controllers/KbdActionsTests.cs` (not to be edited; read-only for this research). + +- `EnumerableConstructor_WhenStoredKeysDifferButKeyEqualsOverlaps_DoesNotThrow` (57-84): seeds two `KaStringAsync` instances via object initializers (`Key="10"`, `Key="1"`), setting only `SourceId`, `Key`, `Delegate` — **`Update` and `ToggleControl` are never set, so they are `null`** (default field values, `KaStringAsync.cs:147-159`). It asserts only that construction does not throw; it never calls `KeyEquals` directly and never observes `Update`. +- `FilterKeys_WhenDistinctStoredKeysCoexist_PreservesKeyboardMatchingSemantics` (87-123): builds its `KbdActions` via `actions.Add("Collection", "10", _ => Task.CompletedTask)` and `actions.Add("Collection", "1", _ => Task.CompletedTask)` — the `Add(string, TKey, VDelegate)` overload (`KbdActions.cs:126-140`), which (per R3) never assigns `Update`/`ToggleControl`, leaving them `null`. It asserts only on `KeyEquals`'s **return value**, indirectly through `FilterKeys`/`ContainsKey`/the indexer (`actions["10"].Should().NotBeNull()`), never on any `Update` side effect. + +**Conclusion:** Option 2 changes only the *argument* passed to `Update` in branch 1 (and only when `Update` is non-null and `Activated`); it does not change `KeyEquals`'s boolean return value, because the guard (`Key.Contains(other)`) is explicitly unchanged per the maintainer decision. Both tests in this file supply a `null` `Update` and assert only on return-value-derived behavior (membership, filtering, indexing). Neither test can be affected by the Option 2 fix. This file does not need to be, and per the maintainer decision must not be, edited. + +## R6 — Stale documentation + +**`KaStringAsync.KeyEquals` XML doc comment** (`KaStringAsync.cs:57-105`): searched in full for any literal reproduction of `Key.Substring(other.Length - 1, 1)` or an equivalent offset formula — none exists. The only related sentence is the paraphrase at lines 80-83 (`Argument contract.` paragraph): + +> "...The guard clause at the top of this method rejects both fail-fast, so branch 1's substring offset expression is never evaluated with a negative start index." + +This sentence remains **true** after the fix (the empty-probe guard still runs before any offset arithmetic), but its implicit justification narrows: today "non-negative" follows solely from `other.Length >= 1` (guard) feeding `other.Length - 1 >= 0`. Under the corrected expression (`Key.IndexOf(other) + other.Length - 1`), non-negativity additionally depends on `Key.IndexOf(other) >= 0`, which holds only because branch 1 is entered exclusively when `Key.Contains(other)` is already `true`. The sentence is not factually wrong, but it should be reworded to name both preconditions so a future reader does not assume the old single-guard justification still fully explains it. Suggested replacement: "...so branch 1's derived offset (`Key.IndexOf(other)` plus the matched length) is never evaluated with a negative start index: `IndexOf` is non-negative because branch 1 only runs when `Contains` already matched, and `other.Length` is at least 1 because of the guard above." No other sentence in the doc comment (the `` latch-contract paragraphs, the ``, or the `` blocks) references the offset arithmetic; all remain accurate because the `Contains` guard and the `KeyEquals` return contract are explicitly unchanged. + +**`QfcCollectionController.cs`:** searched around `GenerateStringKbdAction` (1101-1123) and its caller `RegisterNavigationAsyncAction` (1091-1099) for any comment stating a prefix assumption for the two-digit keys — none exists. The commented-out line 1118 (`//(s) => grp.ItemViewer.LblItemNumber.Text = s,`) documents the intended (never-wired) `Update` callback, not a prefix assumption, and needs no change. + +## R7 — Retention gates from Issue #445 + +Searched the full repository (via `git grep`-equivalent full-text search, not limited to a subdirectory) for the literals `Key.Substring(other.Length - 1, 1)` and `Key.Contains(other)`. Matches: + +- `QuickFiler/Controllers/KaStringAsync.cs` — production source (the code under change). +- `QuickFiler.Test/Controllers/KaStringAsyncTests.cs` — the because-string identified in R4. +- `docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md` — this issue's own record. +- `docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/{spec.md, issue.md, plan.2026-08-21T18-09.md, code-review.2026-08-22T11-30.md, feature-audit.2026-08-22T11-30.md, research/keyboard-action-contract-defects.2026-08-21T18-20.md, evidence/qa-gates/*.md, evidence/baseline/structural-counts.2026-08-22T09-36.md, evidence/issue-updates/followup-substring-defect.2026-08-22T10-46.md, evidence/other/ac-status-summary.2026-08-22T10-50.md}` — all under `docs/features/archive/...-445/`, i.e., #445's now-closed, archived feature folder. +- `docs/features/potential/promoted/2026-08-07-quickfiler-keyboard-action-contract-defects.md` — the promoted-potential source document for #445. +- `docs/features/active/2026-08-27-.../spec.md` and two other unrelated active-feature research documents that merely cite the expression in prose while discussing #445's history. +- `.claude/agent-memory/atomic-executor/project_concurrent_dotnet_coverage_deadlock_and_doccomment_retention_gate.md` — an agent-memory note *describing* the #445 gate methodology (a lesson learned), not an executable gate. + +`docs/features/archive/2026-08-07-.../evidence/baseline/structural-counts.2026-08-22T09-36.md` shows the gate's actual mechanism: a one-time PowerShell command (`git grep -n -F 'TOKEN' -- 'PATHSPEC' | Measure-Object -Line`) run manually during #445's Phase-0 baselining and recorded as a static table in that dated evidence file. It was never wired into a script, hook, or CI workflow. + +Searched explicitly for a **live** enforcement point and found none: +- `.github/` workflows: no match for either literal (grep against `.github` returned no files). +- `.claude/hooks/**` (full listing enumerated): none of the 37 hook files reference either literal or a generic "literal register" / "structural count" gate mechanism by content (file listing inspected; none named or scoped to this feature). +- No `scripts/` directory content matches either literal (the repo-wide search's only non-doc, non-source hits were the two `.cs` files already listed). + +**Conclusion (explicit negative result):** the #445 "structural-count gate" was a one-time, manually-run Phase-0 baseline captured as a dated Markdown evidence artifact inside #445's now-archived feature folder. It is **inert** — not a live script, hook, CI workflow, or test — and it exercised no ongoing enforcement mechanism that would break when `KaStringAsync.cs`'s branch-1 expression changes under #583. No live gate of any kind counts occurrences of `Key.Substring(other.Length - 1, 1)` or `Key.Contains(other)` in this repository today. + +## R8 — Test project conventions + +`QuickFiler.Test/Controllers/KaStringAsyncTests.cs` is the location AC3 targets; it lives at repo-relative path `QuickFiler.Test/Controllers/`, mirroring `QuickFiler/Controllers/`. Framework: MSTest (`Microsoft.VisualStudio.TestTools.UnitTesting`, `[TestClass]`/`[TestMethod]`). Assertion library: FluentAssertions (`.Should().Be(...)`, `.Should().ThrowExactly().WithParameterName(...)`), consistently supplying a `because:`-style trailing string literal as the human-readable rationale for each assertion. Conventions observed in the existing file to match for the new AC3 test: +- A class-level `///` summary (lines 10-17) precedes `[TestClass]`. +- Each `[TestMethod]` uses `// Arrange`, `// Act`, `// Assert` section comments (e.g., lines 79-96). +- Regression-specific tests carry an `// Intent: ...` comment block above the AAA sections explaining the defect being pinned and the pre-fix behavior it replaces (e.g., lines 172-178, 191-197, 217-227, 258-266) — the AC3 test should follow this pattern, naming the issue/defect and stating both the pre-fix (`"0"`) and post-fix (`"1"`) result. +- The shared `NewKa(...)` factory helper (lines 21-26) is the established construction seam and should be reused for the new AC3 test rather than calling the constructor directly. + +## R9 — Edge cases for the registered two-digit key width + +Registered keys (per `QfcCollectionController.GenerateStringKbdAction`, `key = (i + 1).ToString("00")`): `"01"` through `"12"`. For a single-character probe, a key of this format matches (`Contains`) if and only if the probe equals one of its two digits. Enumerating the distinct **match-position** shapes reachable, with the corrected expression `Key.IndexOf(other) + other.Length - 1` (here `other.Length == 1`, so this reduces to `Key.IndexOf(other)`, i.e., the position of the single matched digit) against the current (wrong) `Key.Substring(other.Length - 1, 1)` = `Substring(0, 1)` (always the first character, regardless of match position): + +| Key | Probe | Match position (`IndexOf`) | Corrected `Update` arg | Current (buggy) `Update` arg | Differ? | +|---|---|---|---|---|---| +| `"01"` | `"1"` | 1 (second char) | `"1"` | `"0"` | **Yes** — this is the issue's regression case. | +| `"10"` | `"1"` | 0 (first char) | `"1"` | `"1"` | No — prefix match, both formulas agree. | +| `"11"` | `"1"` | 0 (first occurrence; digit repeats) | `"1"` | `"1"` | No — `IndexOf` returns the first of the two occurrences (index 0), and the first-character formula happens to return the same character, so this key is not a distinguishing case even though the probe occurs twice. | +| `"12"` | `"1"` | 0 (first char) | `"1"` | `"1"` | No — prefix match. | + +For `Key="11"`, `other="1"` specifically: `"1"` occurs at both index 0 and index 1. `string.IndexOf` returns the **first** match, index 0, so `Update` receives `Key[0] = "1"` — the same character branch 3's `Substring(0,1)` would also yield, and the same result the current (unfixed) code already produces for this key. This key does not exercise a behavior change; `"01"` is the only one of the four reachable two-digit keys where the corrected and current expressions diverge, which matches the issue's chosen regression case exactly. + +## Recommendation + +Implement Option 2 exactly as the maintainer recorded it: +1. Leave the branch-1 guard (`Key.Contains(other)`) and the empty/null argument guards unchanged. +2. Replace `Key.Substring(other.Length - 1, 1)` (line 128) with an offset derived from `Key.IndexOf(other)` — recommended form: `Key.Substring(Key.IndexOf(other) + other.Length - 1, 1)`, optionally with an explicit `StringComparison.Ordinal` on the `IndexOf` call for semantic consistency with `Contains` (advisable, not required by any active analyzer gate; see R2). +3. Add the AC3 regression test to `KaStringAsyncTests.cs` following the file's existing `NewKa`/AAA/`because:` conventions (R8), covering `Key="01"`, `other="1"`, non-null `Update`, asserting `"1"`. +4. Reword the one stale because-string identified in R4 (`KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue`) to stop quoting the replaced literal expression, while keeping its asserted value (`"b"`) unchanged, per AC4. +5. Reword the doc-comment sentence identified in R6 to name both preconditions for non-negativity. +6. Do not touch `KbdActionsTests.cs` (R5 confirms no test in that file is affected). +7. No retention-gate script, hook, or CI check needs updating (R7 — none exists live). + +## Rejected alternatives + +- **Option 1 (switch the guard to `StartsWith`)**: explicitly rejected by the maintainer decision; not evaluated further here. +- **Leaving `IndexOf` culture-sensitive vs forcing `StringComparison.Ordinal`**: both are behaviorally equivalent for the actual digit-only input domain (R2) and neither is blocked or required by any live analyzer gate; `StringComparison.Ordinal` is recommended for semantic clarity but is not a correctness requirement for this fix. + +## Testing implications + +- Add exactly one new `[TestMethod]` to `KaStringAsyncTests.cs` for the AC3 regression case (`Key="01"`, `other="1"`, non-null `Update`, asserting `"1"`), following the file's existing MSTest + FluentAssertions + AAA + `because:` conventions (R8) and reusing the `NewKa` helper. +- Reword (do not remove) the AC4 prefix-case assertion's because-string in the existing `KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue` test so it no longer quotes the replaced literal expression (R4); the asserted value `"b"` itself does not change. +- No other existing `KaStringAsyncTests.cs` test requires a value change (R4); `KbdActionsTests.cs` requires no change and must not be edited (R5, maintainer decision). +- No coverage, retention-gate, or CI artifact needs updating as a prerequisite (R7). +- This is unit-level, deterministic, no-I/O test work consistent with repository MSTest/FluentAssertions/AAA conventions; no new test infrastructure or seam is required. diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md new file mode 100644 index 000000000..7c0157904 --- /dev/null +++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md @@ -0,0 +1,334 @@ +# kastringasync-keyequals-contains-offset (Spec) + +- **Issue:** #583 +- **Parent (optional):** none +- **Owner:** drmoisan +- **Last Updated:** 2026-09-12T10-35 +- **Status:** Ready for implementation +- **Version:** 1.0 + +## Context + +- Summary of the bug and its impact: the first branch of KaStringAsync's KeyEquals method + guards on a substring test (Contains) but computes the argument it passes to the Update + callback using prefix-only arithmetic (a Substring offset derived from the probe's own + length, not from where the probe was found). The guard and the offset agree only when the + probe happens to be a prefix of the stored key. When the probe matches at a later position, + the offset expression returns the wrong character. +- Observed environment(s): QuickFiler's keyboard-driven collection navigation, on the + KaStringAsync implementation of the generic keyboard-action list. +- Customer impact and severity: recorded in issue.md as Low (latent). The defect has no + observable effect in the shipped product today because the only production construction + paths for a KaStringAsync instance leave its Update callback null; the branch that carries + the wrong offset is guarded by a null check on Update and therefore never invokes the + callback in production. This is a latent defect, not a live one. +- First observed date and version(s) impacted: recorded 2026-09-11 as a maintainer decision on + issue #583; the underlying code has not changed since it was introduced. + +## Repro & Evidence + +- Steps to reproduce (code-level, not a live user-facing repro given the null-Update finding + above): construct a KaStringAsync instance with a non-null Update callback, an Activated + value of true, and a two-character Key whose second character equals a one-character probe + that is not the key's first character. Call KeyEquals with that probe. See the Regression + Case section below for the exact literal values. +- Expected vs actual behavior: Update should receive the character at the position where the + probe actually matched inside the key. It instead receives a character computed purely from + the probe's own length, which is only correct when the probe is a prefix of the key. +- Logs/screenshots/error snippets: none; this is a pure-logic defect discovered through static + review of KaStringAsync's KeyEquals method, not through an observed runtime failure. The + accompanying research record characterizes framework and repository behavior from source + inspection; it is not a runtime observation. +- Frequency / determinism: deterministic and always reproducible for the input shapes + described above, given a non-null Update. Not applicable in current production use because + Update is never non-null there. + +## Scope & Non-Goals + +- In scope: the offset expression inside the Contains-guarded branch of KaStringAsync's + KeyEquals method, and its regression coverage in the associated test file. +- Out of scope / non-goals: the second branch of KeyEquals (the single-character + non-match branch that toggles a control) and the third branch (the multi-character + non-match branch, whose own Update argument is already the key's first character + unconditionally and is not affected by this change) are both out of scope. No other method + of KaStringAsync, and no other implementation of the shared keyboard-action interface, is in + scope. +- Explicitly excluded systems, integrations, or datasets: the pinned keyboard-matching test in + the QuickFiler.Test project's KbdActions test file is out of scope for editing. That file + must not be modified as part of this change; it is named here only to state that it is + excluded, not to claim it is touched. Likewise, the collection controller that registers the + two-digit keyboard keys, the keyboard-action interface and its other implementations, the + generic keyboard-actions collection class, and the keyboard dispatch handler are cited below + only as historical and structural context for why the defect is currently unreachable in + production; none of them is modified by this change. The archived feature folder that + previously recorded a one-time structural-count baseline for a related, already-closed issue + is likewise cited only as historical context and is not touched. + +## Root Cause Analysis + +- Confirmed root cause: inside KaStringAsync's KeyEquals method, the branch entered when the + stored key contains the probe as a substring computes its Update argument as a Substring + call whose start index is derived only from the probe's own length minus one. That + expression is correct only when the probe is a prefix of the key, because in that case the + probe's length minus one coincides with the index of the last matched character. When the + probe matches at a different position, the two computations diverge and the wrong character + is passed to Update. +- Signals/evidence supporting it: confirmed by direct reading of the current implementation + (KaStringAsync.cs, the KeyEquals method, the Contains-guarded branch) and by the maintainer + decision recorded in issue.md, which independently describes the same divergence and + resolves it. The accompanying research record's finding R1 quotes the exact current + expression and its surrounding structure; finding R9 enumerates the reachable two-digit key + and probe combinations and shows exactly one combination where the current and corrected + expressions disagree. +- Affected components/modules: `QuickFiler/Controllers/KaStringAsync.cs` (the KeyEquals + method) and its regression coverage in + `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`. + +## Proposed Fix + +### Design summary (what changes where) + +Per the binding maintainer decision recorded in issue.md, this change keeps the Contains guard +on the affected branch of KeyEquals unchanged and corrects only the offset expression that +branch uses when it invokes Update. The offset is derived from the match position — the index +at which the probe was actually found inside the key — rather than from the probe's own +length alone. + +The recommended corrected expression, quoted from the research record's finding R1 and its +Recommendation section, replaces the current expression with one derived from IndexOf: the +Substring call's start index becomes the position returned by searching the key for the probe, +plus the probe's own length, minus one. This yields the index of the last character of the +matched span for every match position, not only for a prefix match. The research record notes +this may optionally carry an explicit ordinal comparison argument on the IndexOf call; see the +Comparison Semantics Decision section below for that determination. + +### Boundaries and invariants to preserve + +- The Contains guard on the affected branch is not changed in any way: neither its condition + nor a replacement with a prefix-only test is permitted. This is a binding maintainer + constraint, not a design preference. +- The method's boolean return contract is unchanged: this fix affects only the argument passed + to the Update callback, never the value KeyEquals returns. +- The null-probe and empty-probe guard clauses that run before the affected branch are + unchanged and continue to run first, in the same order. +- The multi-character non-match branch's own Update argument (the key's first character, + unconditional on match position) is a separate, already-correct expression and is not + touched. + +### Dependencies or blocked work + +None. The fix is self-contained to the one method and its regression test file. No other +in-flight work blocks or is blocked by this change. + +### Implementation strategy (what changes, not sequencing) + +#### Files/modules to change + +See the Write Set section below for the authoritative list of paths this change's diff +creates, modifies, or deletes. + +#### Functions/classes/CLI commands impacted + +Only KaStringAsync's KeyEquals method is impacted at the production-code level. No public +signature changes. No CLI or command-surface impact. + +#### Data flow and validation changes + +None. The guard clauses and their exception contracts (null-probe and empty-probe) are +unchanged. Only the internal arithmetic feeding the Update callback's argument changes. + +#### Error handling and logging updates + +None required. No new failure mode is introduced; the corrected expression cannot produce a +negative Substring start index, because the affected branch is only entered after the guard +has already confirmed the probe is present in the key, and the probe's length is already +confirmed to be at least one character by the preceding guard clause. + +#### Rollback/feature-flag considerations (if applicable) + +Not applicable. The change is not behind a feature flag; production behavior does not change +today because Update is null on every production instance, so no rollout risk exists. Rollback +is a plain source revert if ever needed. + +### Technical specifications (interfaces/contracts) + +#### Inputs/outputs and formats + +No change to KeyEquals's public signature (a single string parameter, a boolean return, the +same two exception types on null and empty input). The only observable change under this fix +is the character value passed to a non-null Update callback for a non-prefix match. + +#### Required configuration keys and defaults + +None. + +#### Backward-compatibility expectations + +Full backward compatibility is expected for every existing caller: the guard, the return +value, and the exception contract are all unchanged. The corrected argument value only differs +from today's value for match positions that are not currently exercised by any production +construction path, per the research record's finding R3. + +#### Performance constraints (latency/throughput/memory) + +Not applicable. The change replaces one O(n) substring search argument computation with +another of the same order; no measurable performance difference is expected or required. + +## Comparison Semantics Decision + +The research record's finding R2 establishes that Contains performs an ordinal (culture- +insensitive) search on both the current and the legacy .NET Framework runtime, while the +parameterless IndexOf overload performs a culture-sensitive search using the current culture +on the legacy .NET Framework runtime this project targets. This is the asymmetry that the +built-in specify-comparison analyzer rules exist to flag. The research record further +establishes, as a static characterization derived from documented framework behavior over the +enumerated input alphabet rather than from any runtime observation, that the two search modes +cannot disagree for this method's actual input domain: the registered keys and probes are +restricted to ASCII digit characters, which have no culture-specific collation or +normalization differences under any standard culture. + +The research record also establishes that no analyzer override for either specify-comparison +rule exists anywhere in the repository's analyzer configuration, and that the repository's +blanket analyzer-severity default is set to the lowest, non-blocking severity level. Neither +the analyzer rebuild command nor the nullable rebuild command promotes that severity level to +a build failure. An explicit ordinal StringComparison argument on the IndexOf call is +therefore not required by any active gate. + +Decision: the implementation adds an explicit ordinal StringComparison argument to the +IndexOf call used in the corrected offset expression. This is not required for either rebuild +command to pass, and it does not change behavior for the method's actual input domain, but it +makes the corrected expression's comparison semantics textually consistent with the ordinal +Contains guard it depends on, and it removes any ambiguity for a future reader or a future +widening of the input domain. + +## Retention Gate Finding + +The research record's finding R7 searched the full repository for the literal expressions this +change replaces and confirmed that the only prior structural-count check against them was a +one-time, manually run baseline captured as a dated evidence table inside the archived feature +folder for a separate, already-closed issue. That baseline was never wired into a script, a +repository hook, or a continuous-integration workflow, and no such live enforcement point exists +today. This change requires no update to any retention gate, coverage gate, or automated count, +because none currently counts the literal being replaced. + +## Assumptions, Constraints, Dependencies + +- Assumptions (environment, data, access): the input alphabet reaching KeyEquals in production + is restricted to ASCII digit characters, per the research record's findings R2 and R9. No + change to that alphabet is assumed or required by this fix. +- Constraints (budget, performance, compatibility): the binding maintainer decision constrains + the guard clause to remain a Contains-based substring test; no alternative guard design may + be substituted. +- External dependencies (services, libraries, releases): none. + +## Data / API / Config Impact + +- User-facing or API changes: none. KeyEquals's public signature and exception contract are + unchanged. +- Data or migration considerations: none. +- Logging/telemetry updates (if any): none. +- Compatibility notes (CLI flags, config schemas, versioning): none. + +## Test Strategy + +- Regression tests to add or update: one new regression test method is added to + `QuickFiler.Test/Controllers/KaStringAsyncTests.cs` covering the two-digit-width non-prefix + case described in the Regression Case section below. Following the file's established + conventions (per the research record's findings R4 and R8), the new test uses the existing + NewKa factory helper, MSTest attributes, FluentAssertions assertions with a because-style + explanation string, Arrange/Act/Assert section comments, and an Intent comment block naming + the defect being pinned and both the pre-fix and post-fix results. +- Unit tests for the fixed behavior and boundaries: the new regression test, plus the existing + prefix-case test, together bound the corrected behavior at both the previously-passing + (prefix) case and the previously-failing (non-prefix) case. +- Edge cases and negative scenarios: the existing null-probe and empty-probe tests already + cover the guard clauses that run before the affected branch and require no change, because + those guards are unchanged by this fix. +- Error handling and logging verification: not applicable; no new error path is introduced. +- Coverage impact and targets for changed lines/modules: the changed line is already exercised + by existing tests today; the new regression test adds coverage for a previously-untested + code path (a non-prefix match) rather than merely re-exercising an already-covered one. +- Toolchain commands to run (format, lint, type-check, test): the CSharpier format command, + the CSharpier check command, the analyzer rebuild command, the nullable rebuild command, and + the MSTest run against the QuickFiler.Test assembly, all as defined in this repository's + CLAUDE.md, executed in that order and restarted from the beginning if any step fails or + auto-fixes any file. +- Manual validation steps (if required): none; the defect has no observable production effect + today, so no manual verification beyond the automated toolchain is required. + +Additionally, one existing test's explanatory because-string is reworded because it quotes the +literal expression being replaced; its asserted value does not change. See the Regression Case +section below for that test's identity and current text. + +## Regression Case + +The primary regression case, carried forward from issue.md and confirmed by the research +record's finding R9 as the only reachable two-digit key and single-character probe combination +where the current and corrected expressions disagree: a Key equal to "01" and an other equal +to "1", with a non-null Update, must cause Update to receive "1". Under the current, +unfixed expression, Update instead receives "0". + +The pre-existing prefix-case example, which must remain unaffected by this fix: a Key equal to +"abc" and an other equal to "ab", with a non-null Update, causes Update to receive "b" under +both the current and the corrected expression, because "ab" is a prefix of "abc" and the two +formulas agree whenever the probe is a prefix of the key. + +The one existing test whose because-string quotes the literal expression being replaced is the +test named for a contains-match while activated that invokes Update and returns true, in +`QuickFiler.Test/Controllers/KaStringAsyncTests.cs`. Its asserted value ("b") does not change; +only its explanatory string is reworded so it no longer quotes the replaced literal expression, +describing instead the last-character-of-the-matched-span reasoning that the corrected +expression embodies. + +## Acceptance Criteria + +- [x] AC1: The recorded maintainer decision (retain the Contains guard, correct the offset + arithmetic) is reflected in the implementation; the branch-one guard text is unchanged and + no StartsWith call is introduced in `QuickFiler/Controllers/KaStringAsync.cs`. +- [x] AC2: KaStringAsync's KeyEquals branch-one derives its Update argument from the match + position via Key.IndexOf(other), so the character passed to Update is the last character of + the matched span for a non-prefix match as well as for a prefix match. +- [x] AC3: A regression test in `QuickFiler.Test/Controllers/KaStringAsyncTests.cs` covers the + two-digit-width non-prefix case described in the Regression Case section above, with a + non-null Update, and asserts that the received argument equals the corrected value stated + there. +- [x] AC4: The pre-existing prefix-case behavior is preserved: for a Key equal to "abc" and an + other equal to "ab", Update still receives "b". +- [x] AC5: The pinned keyboard-matching test in the QuickFiler.Test KbdActionsTests file passes + unchanged, and that file is not modified by this change. +- [x] AC6: The full C# toolchain passes in order: CSharpier check, the analyzer rebuild, the + nullable rebuild, and the MSTest run. + +## Write Set + +- `QuickFiler/Controllers/KaStringAsync.cs` +- `QuickFiler.Test/Controllers/KaStringAsyncTests.cs` +- `docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md` +- `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md` +- `docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md` +- `docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md` +- `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/` +- `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/` +- `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/` +- `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/other/` + +## Risks & Mitigations + +- Technical or operational risks: minimal. The change is confined to one expression inside one + method, guarded by the same conditions as before, in a code path that no production + construction site currently exercises with a non-null Update. The primary risk is + introducing a regression in the already-passing prefix-case test; this is mitigated by + keeping that test's asserted value unchanged and only rewording its explanatory string. +- Mitigations and rollbacks: the full toolchain run (format, analyzer rebuild, nullable + rebuild, test run) after the change is the primary verification gate. Rollback is a plain + source revert of the two changed source files if a regression is discovered. + +## Rollout & Follow-up + +- Release/rollout steps: standard pull-request merge; no flag, migration, or staged rollout is + required, because the change has no observable effect on current production behavior. +- Post-fix monitoring or clean-up tasks: none identified. If a future change wires a non-null + Update callback into a production KaStringAsync construction site, that change should + re-verify this corrected offset behavior against the newly reachable code path. +- Links: issue #583; the research record at + `docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md`.