Repository navigation
KaStringAsync.KeyEquals branch 1 computes a prefix-only Substring offset under a Contains guard #583
Description
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.
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 whenotheris a prefix ofKey.
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 (QuickFiler/Controllers/QfcCollectionController.cs:1363-1385) 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:1376-1383passesnullfor bothupdateandtoggleControl.KbdActions.Add(string, TKey, VDelegate)builds its element withUClass instance = new()(KbdActions.cs:99), 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.
Why it was excluded from #445
Fixing it correctly requires a design decision that #445 was not scoped to make: whether branch 1 should test StartsWith instead of Contains. That is a keyboard-filtering behaviour change, and the current substring semantics are pinned by an existing test at QuickFiler.Test/Controllers/KbdActionsTests.cs:71-76. Changing it would invert that test rather than leave it passing.
Per the CLAUDE.md Bugfix Workflow, section 2 ("If you uncover deeper design problems, open a new issue instead of widening scope"), the defect was recorded and deferred rather than folded into #445. Issue #445's acceptance criterion AC19 requires this issue to exist, and its P4-T4 gate verifies the expression was left unchanged:
Key.Substring(other.Length - 1, 1)inKaStringAsync.cs— count 1, unchanged.Key.Contains(other)inKaStringAsync.cs— count 1, unchanged.- The
.Be("b")assertion atKaStringAsyncTests.cs(forKey = "abc",other = "ab", the prefix case) — count 1, unchanged.
Options to consider
- Change branch 1 to
Key.StartsWith(other). Makes the guard and the offset arithmetic consistent. Requires re-deciding, and re-pinning, the keyboard matching semantics currently asserted byKbdActionsTests.cs:71-76. - Keep
Containsand correct the offset. For a substring match, compute the index viaKey.IndexOf(other)and derive the intended character from that, leaving matching semantics untouched. - Document the prefix precondition and constrain registration so non-prefix matches cannot arise.
Option 2 is the narrowest and does not change which rows match a probe; option 1 is the larger behavioural change. The choice should be made deliberately, with a regression test for the two-digit-width case (Key = "01", other = "1").
Acceptance Criteria
- A decision is recorded between
StartsWithmatching, corrected offset arithmetic underContains, or a documented prefix precondition. -
KaStringAsync.KeyEqualsbranch 1 no longer computes a substring offset that is meaningless for a non-prefix match. - A regression test covers the two-digit-width non-prefix case (for example
Key = "01"withother = "1") with a non-nullUpdate. -
QuickFiler.Test/Controllers/KbdActionsTests.cs:71-76either still passes unchanged, or its change is explicitly justified as the intended behavioural change. - The full C# toolchain passes:
csharpier check ., the analyzer build, the nullable build, and the MSTest suite.
References
- Parent issue: Bug: quickfiler-keyboard-action-contract-defects #445 (
quickfiler-keyboard-action-contract-defects), acceptance criterion AC19. - Spec:
docs/features/active/2026-08-07-quickfiler-keyboard-action-contract-defects-445/spec.md, "Scope & Non-Goals" and "Rollout & Follow-up". - Research:
docs/features/active/2026-08-07-quickfiler-keyboard-action-contract-defects-445/research/keyboard-action-contract-defects.2026-08-21T18-20.md.
Activity
docs(epic): record #445 follow-up PR #591 and the analyzer-skew mecha…
drmoisan commentedon Sep 11, 2026
Maintainer decision recorded 2026-09-11
Resolution: Option 2 - keep the
Containsguard and correct the offset arithmetic.Branch 1 of
KaStringAsync.KeyEqualskeeps its substring test. TheUpdateargument is computed from the match position (Key.IndexOf(other)) so thatUpdatereceives the last matched character for every matching row, not only for prefix matches. For the two-digit registration"01"through"12", typing1continues to match01,10,11, and12; the01row now yields1instead of0.Rationale: this is the only option with no user-visible change to keyboard filtering, and
Updateis null on every production instance today, so the correction carries no runtime risk. The pinned testFilterKeys_WhenDistinctStoredKeysCoexist_PreservesKeyboardMatchingSemanticsmust continue to pass unchanged.This issue is scheduled into the consolidated bug parallel run being planned on 2026-09-11.
drmoisan commentedon Sep 14, 2026
Delivered by PR #874, merged as 39ce289 on 2026-09-13.
KaStringAsync.KeyEquals branch-1 offset arithmetic fixed per the maintainer's recorded Option 2 decision: the Update offset now derives from Key.IndexOf(other, StringComparison.Ordinal) + other.Length - 1 instead of prefix-only arithmetic. The Contains guard is unchanged and no StartsWith was introduced.
6/6 acceptance criteria. Single clean toolchain pass: CSharpier 0 of 1624 files needing formatting, analyzer and nullable rebuilds both 0 errors, coverage-instrumented MSTest run 1394/1394 passed. KaStringAsync.cs changed-line coverage 100 percent (6/6) with no regression on any previously covered line. The pinned KbdActionsTests file was unmodified and passed 4/4 both before and after.
Regression case from the issue confirmed: Key="01", other="1", non-null Update now yields "1" where it previously yielded "0".
Closed manually - the PR carried no closing keyword.