diff --git a/docs/memory/cli/edit.md b/docs/memory/cli/edit.md index 4e871da..5dafc5a 100644 --- a/docs/memory/cli/edit.md +++ b/docs/memory/cli/edit.md @@ -13,7 +13,7 @@ description: "`idea edit` two-form contract: inline replacement (two-arg form) v | `idea edit "text"` | Inline replacement — the quick one-liner and scripting path. Never launches an editor (tripwire-tested) and is byte-for-byte the pre-change behavior: same validation, same output. | | `idea edit ` | Opens the resolved editor on a temp file containing the idea's **decoded** text (real newlines, real backslashes — `Idea.Text`); on clean exit the buffer is post-processed and persisted via the existing `idea.Edit`. | -**Resolve before launch.** The one-arg form resolves the match via the existing `idea.Show` (LoadFile + `RequireSingle`, `FilterAll`) **before** launching the editor — an ambiguous or unmatched query is refused with the match list and the editor never opens. This reuses `idea.Edit`'s exact match semantics (substring, case-insensitive, ID or text) with zero new resolver code. +**Resolve before launch.** The one-arg form resolves the match via the existing `idea.Show` (LoadFile + `RequireSingle`, `FilterAll`) **before** launching the editor — an ambiguous or unmatched query is refused with the match list and the editor never opens. This reuses `idea.Edit`'s match semantics (substring, case-insensitive, ID or text) — no edit-specific resolver code was added; resolution flows entirely through the shared `RequireSingle`. The shared resolver behavior did change, though: since `260615-m2qx-exact-id-match-precedence`, `RequireSingle` applies an **exact-ID tiebreaker**: when a query produces multiple matches but exactly one of them is an exact (case-insensitive) ID match, that idea wins over incidental substring text matches instead of aborting — so `idea edit jznd` resolves cleanly even when `jznd` also appears as cross-reference text inside another idea. See `structure.md` § Query resolution for the full contract; `Match`/`FindAll` keep pure substring semantics for `list`/search. ## Editor resolution chain diff --git a/docs/memory/cli/index.md b/docs/memory/cli/index.md index 5d5066d..e67475e 100644 --- a/docs/memory/cli/index.md +++ b/docs/memory/cli/index.md @@ -10,5 +10,5 @@ description: "CLI source structure (cmd/idea + internal/idea + version wiring), | [edit](edit.md) | `idea edit` two-form contract: inline replacement (two-arg form) vs. $EDITOR round-trip (`edit `) — editor resolution chain, temp-file mechanics, edge/exit semantics, and the fake-editor test seam | 2026-06-12 | | [list](list.md) | `idea list`/`ls` rendering contract: TTY-aware rune-safe text truncation, the `--full` flag, the optional `[id...]` positional filter, ANSI color (NO_COLOR-gated), and the pipe contract that keeps piped output canonical | 2026-06-13 | | [prune](prune.md) | Bulk-remove subcommand (`idea prune`): the TTY × `--force` decision matrix (pipe dry-run vs. interactive `[y/N]` confirm), the leading stderr count header, TTY-aware truncation/color/`--full` listing, stdout/stderr channel split, exit codes, the deliberate non-archival design, and the `removeIdeaAt` seam shared with `Rm` | 2026-06-13 | -| [structure](structure.md) | Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG system backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping | 2026-06-13 | +| [structure](structure.md) | Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG system backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the query-resolution layer (`Match`/`FindAll` pure substring vs. `RequireSingle`'s exact-ID precedence over incidental substring matches), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping | 2026-06-13 | | [update](update.md) | Self-update subcommand (`idea update`): Homebrew-backed upgrade flow, non-brew install fallback hint, and the `--skip-brew-update` flag | 2026-06-10 | diff --git a/docs/memory/cli/structure.md b/docs/memory/cli/structure.md index 65e781b..88a70de 100644 --- a/docs/memory/cli/structure.md +++ b/docs/memory/cli/structure.md @@ -1,5 +1,5 @@ --- -description: "Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG system backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping" +description: "Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG system backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the query-resolution layer (`Match`/`FindAll` pure substring vs. `RequireSingle`'s exact-ID precedence over incidental substring matches), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping" --- # CLI Source Structure @@ -193,6 +193,16 @@ Output is always canonical: `- ` bullet, no leading whitespace, date present, si The behavior contract is documented for external consumers in `../../specs/backlog-format.md` and `../../specs/overview.md`. +### Query resolution (`Match` / `FindAll` / `RequireSingle`) + +`internal/idea/idea.go` owns the query-matching layer shared by every command that takes a `` (`show`/`done`/`reopen`/`edit`/`rm`). Two distinct contracts live here, deliberately split: + +- **`Match` / `FindAll` — pure substring semantics.** `Match(query, idea)` is true when `query` is a case-insensitive substring of *either* the `ID` or the `Text` (`strings.Contains` on both, lowercased). `FindAll` collects every `Match` hit under a `FilterKind`. These are the **search/list** predicates — `idea list [id...]` and the internal `Prune` collection rely on substring breadth, so they stay pure substring (Constitution VI: search/list semantics are part of the public contract). They are intentionally untouched by exact-ID precedence. + +- **`RequireSingle` — exact-ID precedence over substring matches** (added by `260615-m2qx-exact-id-match-precedence`). `RequireSingle` collects matches via `Match`, then in its `len(matches) > 1` branch scans the **already-collected match set** with `strings.EqualFold(m.ID, query)` *before* emitting the ambiguity error. If **exactly one** match is an exact-ID hit, that idea wins (returned with its original index) over incidental substring text matches; **zero or more-than-one** exact-ID hits fall through to the existing `Multiple matches: … Be more specific or use the exact ID.` error unchanged. This fixes the bug where passing a canonical 4-char ID failed because that ID string also appeared as substring text inside another idea (e.g. a cross-reference `[jznd]` written into a different idea's body) — the documented "use the exact ID" escape hatch was exactly what aborted. All five resolver-sharing commands (`edit`/`rm`/`show`/`done`/`reopen`) benefit at the single seam. + + Two properties are load-bearing: the precedence scan iterates `matches` (already post-`matchesFilter`), so a filtered-out exact-ID idea — e.g. a done idea under `FilterOpen` — is never force-selected (filter semantics preserved); and the `exactCount > 1` case deliberately falls through to the ambiguity error rather than silently picking one (defensive — Constitution VI guarantees unique IDs within a file, so it should never occur). Regression coverage: `TestRequireSingle_ExactIDBeatsSubstring` in `internal/idea/idea_test.go` (table-driven per Constitution V; GIVEN one exact-ID idea + one substring-only idea, WHEN `RequireSingle`, THEN the exact-ID owner returns with no error). The `Match`-substring contract for `show`/`done`/`reopen`/`edit`/`rm` is also noted in `edit.md` and the user-facing query semantics in `../../specs/overview.md`. + ### Explicit canonicalizer & adoption (`idea fmt`) `idea fmt` (added by `260612-4m3a-add-fmt-canonicalizer-adoption`) is the explicit, gofmt-style trigger for the canonical write above: it rewrites the whole backlog into canonical form with no semantic change required, so the normalize-on-write churn can land as its own commit. `fmt` is the **only explicit whole-file write verb** — mutating CRUD commands keep their incidental normalize-on-write; `list`/`show` stay non-mutating. diff --git a/fab/changes/260615-m2qx-exact-id-match-precedence/.history.jsonl b/fab/changes/260615-m2qx-exact-id-match-precedence/.history.jsonl new file mode 100644 index 0000000..97864ff --- /dev/null +++ b/fab/changes/260615-m2qx-exact-id-match-precedence/.history.jsonl @@ -0,0 +1,12 @@ +{"action":"enter","driver":"fab-new","event":"stage-transition","stage":"intake","ts":"2026-06-15T04:34:29Z"} +{"args":"Fix: exact-ID match must take precedence over incidental substring matches in RequireSingle (idea resolver). Backlog [m2qx].","cmd":"fab-new","event":"command","ts":"2026-06-15T04:34:29Z"} +{"delta":"+4.4","event":"confidence","score":4.4,"trigger":"calc-score","ts":"2026-06-15T04:35:19Z"} +{"delta":"+0.0","event":"confidence","score":4.4,"trigger":"calc-score","ts":"2026-06-15T04:35:24Z"} +{"cmd":"fab-fff","event":"command","ts":"2026-06-15T04:36:15Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"apply","ts":"2026-06-15T04:36:26Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"review","ts":"2026-06-15T04:38:49Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"hydrate","ts":"2026-06-15T04:42:13Z"} +{"event":"review","result":"passed","ts":"2026-06-15T04:42:13Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"ship","ts":"2026-06-15T04:44:31Z"} +{"action":"enter","driver":"git-pr","event":"stage-transition","stage":"review-pr","ts":"2026-06-15T04:46:06Z"} +{"event":"review","result":"passed","ts":"2026-06-15T04:50:30Z"} diff --git a/fab/changes/260615-m2qx-exact-id-match-precedence/.status.yaml b/fab/changes/260615-m2qx-exact-id-match-precedence/.status.yaml new file mode 100644 index 0000000..58054f9 --- /dev/null +++ b/fab/changes/260615-m2qx-exact-id-match-precedence/.status.yaml @@ -0,0 +1,51 @@ +id: m2qx +name: 260615-m2qx-exact-id-match-precedence +created: 2026-06-15T04:34:29Z +created_by: sahil-noon +change_type: fix +issues: [] +progress: + intake: done + apply: done + review: done + hydrate: done + ship: done + review-pr: done +plan: + generated: true + task_count: 2 + acceptance_count: 11 + acceptance_completed: 11 +confidence: + certain: 4 + confident: 2 + tentative: 0 + unresolved: 0 + score: 4.4 + fuzzy: true + dimensions: + signal: 89.2 + reversibility: 82.5 + competence: 88.3 + disambiguation: 87.5 +stage_metrics: + intake: {started_at: "2026-06-15T04:34:29Z", driver: fab-new, iterations: 1, completed_at: "2026-06-15T04:36:26Z"} + apply: {started_at: "2026-06-15T04:36:26Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-15T04:38:49Z"} + review: {started_at: "2026-06-15T04:38:49Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-15T04:42:13Z"} + hydrate: {started_at: "2026-06-15T04:42:13Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-15T04:44:31Z"} + ship: {started_at: "2026-06-15T04:44:31Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-15T04:46:06Z"} + review-pr: {started_at: "2026-06-15T04:46:06Z", driver: git-pr, iterations: 1, completed_at: "2026-06-15T04:50:30Z"} +prs: + - https://github.com/sahil87/idea/pull/22 +true_impact: + added: 0 + deleted: 0 + net: 0 + excluding: + added: 0 + deleted: 0 + net: 0 + computed_at: "2026-06-15T04:44:31Z" + computed_at_stage: hydrate +# true_impact: lazily created on first apply-finish (no placeholder here). +last_updated: 2026-06-15T04:50:30Z diff --git a/fab/changes/260615-m2qx-exact-id-match-precedence/intake.md b/fab/changes/260615-m2qx-exact-id-match-precedence/intake.md new file mode 100644 index 0000000..01cf771 --- /dev/null +++ b/fab/changes/260615-m2qx-exact-id-match-precedence/intake.md @@ -0,0 +1,125 @@ +# Intake: Exact-ID Match Precedence in RequireSingle + +**Change**: 260615-m2qx-exact-id-match-precedence +**Created**: 2026-06-15 + +## Origin + +Originated from backlog item `[m2qx]` (a BUG report), captured after the user hit the bug while running `idea edit --main jznd ""`. + +> BUG: `idea edit ` refuses with "Multiple matches" when the EXACT 4-char ID is passed but that ID string also appears as a SUBSTRING inside another idea's text. EXPECTED: an exact ID match should win over incidental substring matches — passing the canonical ID is the documented escape hatch ("use the exact ID") yet here it's exactly what fails. REPRO: backlog has idea [jznd] (the one to edit) and a second idea [qg64] whose body contains the literal text "[jznd]" (a cross-reference). Running `idea edit --main jznd ""` matched BOTH — [jznd] by its ID, [qg64] because "jznd" is a substring of its text — and aborted with "ERROR: Multiple matches:" listing both. WORKAROUND USED: edited backlog.md directly with a text editor. + +Interaction mode: one-shot synthesis from the backlog report, with the fix approach pre-agreed with the user. The bug report listed three fix options; **option 1 (exact-ID precedence at the resolver layer)** was selected as cleanest because it preserves substring search for non-ID queries and requires no new CLI surface. The user also agreed that **unit-level testing is sufficient** (no separate CLI integration test) because `edit`/`rm`/`show`/`done`/`reopen` all funnel through the single `RequireSingle` resolver. + +## Why + +1. **Problem**: The shared query resolver `RequireSingle` in `src/internal/idea/idea.go` treats *any* query that produces more than one hit as ambiguous and aborts with `Multiple matches: ... Be more specific or use the exact ID.` It has no notion of precedence between match *kinds*. The matching predicate `Match` ORs an ID case-insensitive-substring check with a case-insensitive text-substring check. So when a query equals one idea's exact 4-char ID but that same string also happens to appear as a substring inside another idea's *text* (e.g. a cross-reference `[jznd]` written into idea `[qg64]`'s body), BOTH ideas match and the command aborts — even though the user passed the canonical, documented, unambiguous ID. + +2. **Consequence if unfixed**: The error message instructs the user to "use the exact ID" — which is exactly what they already did. There is no in-CLI escape hatch; the only workaround is hand-editing `fab/backlog.md`, bypassing the tool entirely (which defeats the tool's reason to exist per Constitution Principle I). The bug affects every command that shares the matcher: `edit` / `rm` / `show` / `done` / `reopen`. + +3. **Why this approach over alternatives**: The bug report offered three options. Option 2 (a new `--id` selector flag) adds CLI surface and burdens the user with knowing about a special flag. Option 3 (prefer the exact-ID hit only when ambiguity mixes exactly one exact-ID hit with substring-only hits) is effectively a subset of option 1. **Option 1** — exact-ID precedence inside `RequireSingle` only — is the cleanest: it fixes all five affected commands at the shared seam, requires no new flags or dependencies, and leaves `idea list`/search substring semantics untouched. + +## What Changes + +### Resolver: `RequireSingle` exact-ID precedence (`src/internal/idea/idea.go`) + +`RequireSingle` is the only function modified. Today its `len(matches) > 1` branch fires before any check for an exact-ID winner: + +```go +func RequireSingle(query string, ideas []Idea, filter FilterKind) (Idea, int, error) { + var matches []Idea + var indices []int + for i, idea := range ideas { + if !matchesFilter(idea, filter) { + continue + } + if Match(query, idea) { + matches = append(matches, idea) + indices = append(indices, i) + } + } + + if len(matches) == 0 { + return Idea{}, -1, fmt.Errorf("No idea matching '%s'", query) + } + if len(matches) > 1 { + // ... aborts with "Multiple matches: ..." + } + return matches[0], indices[0], nil +} +``` + +The fix inserts an **exact-ID precedence pass over the already-collected match set** (`matches`/`indices`) *before* the `len(matches) > 1` ambiguity branch fires. Concretely: + +- After collecting `matches`/`indices`, scan that match set for ideas whose `ID` equals the query **case-insensitively** (`strings.EqualFold(idea.ID, query)`). +- **If exactly one match has an exact-ID equality with the query**, select that idea (return it with its original index), bypassing the "Multiple matches" error. Substring-only matches in other ideas are ignored in that case. +- **If zero or more-than-one** of the matches are exact-ID hits, fall through to the existing logic unchanged (zero → existing single-match return at the bottom or the >1 ambiguity error as appropriate; two-plus → the existing ambiguity error). This preserves current behavior for every case except the exact-one-ID-among-many scenario. + +Illustrative shape (not prescriptive — apply may structure it differently as long as the semantics hold): + +```go +// Exact-ID precedence: if exactly one matched idea's ID equals the +// query (case-insensitive), it wins over incidental substring matches. +if len(matches) > 1 { + exactIdx := -1 + exactCount := 0 + for j, m := range matches { + if strings.EqualFold(m.ID, query) { + exactIdx = j + exactCount++ + } + } + if exactCount == 1 { + return matches[exactIdx], indices[exactIdx], nil + } +} +``` + +### Explicitly NOT changed + +- **`Match` and `FindAll` are untouched.** They are the search/list predicates; case-insensitive substring matching is the *desired* behavior there. Changing `Match` would alter `idea list`/search semantics (Constitution Principle VI — output formats and search behavior are part of the public contract). The fix lives strictly at the `RequireSingle` resolver layer. +- No CLI/`cmd` changes. No new flags. No new dependencies. No format/output contract changes. + +### Edge cases (both already settled) + +1. **Query equals two ideas' IDs** — shouldn't happen, since Constitution Principle VI guarantees IDs are unique within a single backlog file. If it somehow does (`exactCount > 1`), the fix deliberately **falls through to the existing ambiguity error** rather than silently picking one. The precedence applies *only* when there is exactly one exact-ID match among the matches. +2. **Filter scoping** — the exact-ID scan operates over the **already-filtered match set** (`matchesFilter` has already been applied during collection). A filtered-out exact-ID idea (e.g. a done idea under `FilterOpen`) is therefore NOT force-selected — filter semantics are preserved. This falls out naturally because the scan iterates `matches`, not the raw `ideas` slice. + +### Regression test (`src/internal/idea/idea_test.go`) + +Add a focused / table-driven regression test, e.g. `TestRequireSingle_ExactIDBeatsSubstring`: + +- GIVEN two ideas where idea[0] has `ID == "jznd"` and idea[1] has text containing the substring `jznd` (e.g. `"see related [jznd] for context"`) — so both match query `"jznd"` via `Match`. +- WHEN calling `RequireSingle("jznd", ideas, FilterOpen)`. +- THEN it returns idea[0] (the exact-ID owner) at index 0 with **no error** (previously this aborted with "Multiple matches"). + +Unit-level coverage is sufficient and agreed: all five affected commands (`edit`/`rm`/`show`/`done`/`reopen`) funnel through `RequireSingle`, so a resolver-level test exercises the fix for all of them. Follows the project's table-driven convention (Constitution Principle V) alongside the existing `TestRequireSingle_*` tests. + +## Affected Memory + +- `cli/structure`: (modify) The CLI memory domain documents the resolver / per-subcommand matcher behavior. Record that `RequireSingle` applies exact-ID precedence (an exact case-insensitive ID match among the candidates wins over incidental substring text matches), while `Match`/`FindAll` keep pure substring semantics for `list`/search. Final file placement (`structure.md` vs. a dedicated note) is a hydrate-time decision. + +## Impact + +- **Code**: `src/internal/idea/idea.go` — single function `RequireSingle` (one added precedence pass). `src/internal/idea/idea_test.go` — one added regression test. +- **APIs / behavior**: `RequireSingle`'s signature is unchanged. Behavior changes only in the previously-aborting "one exact-ID hit + N substring-only hits" case, which now resolves to the exact-ID idea. All other inputs behave identically. +- **Commands affected (improved, no change needed in their code)**: `idea edit`, `idea rm`, `idea show`, `idea done`, `idea reopen` — all consume `RequireSingle`. +- **Dependencies**: none added. `strings.EqualFold` is already in the standard library and `strings` is already imported. +- **Constitution alignment**: Principle IV (logic stays in `internal/idea`), Principle V (table-driven test, real data), Principle VI (IDs unique → the `exactCount > 1` guard is defensive). No format/output contract drift. + +## Open Questions + +None. The fix approach, the non-goals (don't touch `Match`/`FindAll`), both edge cases, and the test strategy were all pre-agreed in the synthesized description. + +## Assumptions + +| # | Grade | Decision | Rationale | Scores | +|---|-------|----------|-----------|--------| +| 1 | Certain | Apply exact-ID precedence in `RequireSingle` only; leave `Match`/`FindAll` untouched | Pre-agreed (option 1); Constitution Principle VI makes search/output semantics a public contract — changing `Match` would alter `idea list`/search | S:95 R:80 A:90 D:95 | +| 2 | Certain | Exact-ID equality is case-insensitive (`strings.EqualFold(idea.ID, query)`) | Matches the existing case-insensitive `Match` semantics and IDs are lowercase by Principle VI; no new dependency | S:90 R:85 A:90 D:90 | +| 3 | Certain | When `exactCount > 1`, fall through to the existing "Multiple matches" error (no silent pick) | Explicitly specified; Principle VI guarantees unique IDs so this is a defensive branch | S:95 R:90 A:95 D:95 | +| 4 | Certain | Scope the exact-ID scan to the already-filtered match set so filter semantics are preserved | Explicitly specified; iterating `matches` (post-`matchesFilter`) achieves this naturally | S:95 R:85 A:90 D:95 | +| 5 | Confident | Unit-level regression test in `idea_test.go` is sufficient; no CLI integration test | Pre-agreed — all five affected commands funnel through `RequireSingle`, so a resolver test covers them all | S:85 R:75 A:85 D:80 | +| 6 | Confident | Affected memory is `cli/structure` (modify); exact file placement deferred to hydrate | Memory index lists the resolver/matcher behavior under the cli domain; placement is reversible at hydrate | S:75 R:80 A:80 D:70 | + +6 assumptions (4 certain, 2 confident, 0 tentative, 0 unresolved). diff --git a/fab/changes/260615-m2qx-exact-id-match-precedence/plan.md b/fab/changes/260615-m2qx-exact-id-match-precedence/plan.md new file mode 100644 index 0000000..d152d56 --- /dev/null +++ b/fab/changes/260615-m2qx-exact-id-match-precedence/plan.md @@ -0,0 +1,102 @@ +# Plan: Exact-ID Match Precedence in RequireSingle + +**Change**: 260615-m2qx-exact-id-match-precedence +**Intake**: `intake.md` + +## Requirements + +### Resolver: Exact-ID Precedence in `RequireSingle` + +#### R1: Exact-ID match wins over incidental substring matches +`RequireSingle` SHALL select a single idea when exactly one of the candidate matches has an `ID` equal to the query (case-insensitive), even when other candidates match only because the query is a substring of their text. The exact-ID owner MUST be returned with its original index and no error. + +- **GIVEN** a backlog with idea[0] `{ID: "jznd", Text: "the idea to edit"}` and idea[1] `{ID: "qg64", Text: "see related [jznd] for context"}`, both matching query `"jznd"` via `Match` +- **WHEN** `RequireSingle("jznd", ideas, FilterAll)` is called +- **THEN** it returns idea[0] (ID `"jznd"`) at index 0 with no error +- **AND** the previous "Multiple matches" abort no longer occurs for this input + +#### R2: Exact-ID equality is case-insensitive +The exact-ID precedence comparison SHALL use `strings.EqualFold(match.ID, query)` so it aligns with the existing case-insensitive `Match` semantics and introduces no new dependency. + +- **GIVEN** a candidate match whose `ID` equals the query under case folding +- **WHEN** the precedence pass scans the match set +- **THEN** that candidate is counted as an exact-ID hit regardless of letter case + +#### R3: Zero or multiple exact-ID hits fall through unchanged +When the number of exact-ID hits among the matches is not exactly one, `RequireSingle` MUST preserve its existing behavior: a single overall match returns normally, and two-or-more matches (including the defensive `exactCount > 1` case) return the existing "Multiple matches" ambiguity error with no silent pick. + +- **GIVEN** a query that matches multiple ideas where none (or more than one) is an exact-ID hit +- **WHEN** `RequireSingle` is called +- **THEN** it returns the existing "Multiple matches: ... Be more specific or use the exact ID." error + +#### R4: Filter semantics are preserved +The exact-ID precedence pass MUST operate over the already-filtered match set (post-`matchesFilter`), so an exact-ID idea excluded by the filter is never force-selected. + +- **GIVEN** an exact-ID idea filtered out by the active `FilterKind` +- **WHEN** `RequireSingle` runs +- **THEN** the filtered-out idea is not present in the match set and therefore cannot be selected by the precedence pass + +### Non-Goals + +- Modifying `Match` or `FindAll` — they keep pure case-insensitive substring semantics for `idea list`/search (Constitution Principle VI; public contract). +- Any CLI/`cmd` changes, new flags, new dependencies, or output/format contract changes. +- A CLI integration test — unit-level coverage at the resolver is sufficient because `edit`/`rm`/`show`/`done`/`reopen` all funnel through `RequireSingle`. + +### Design Decisions + +1. **Exact-ID precedence at the resolver only**: Add the precedence pass inside `RequireSingle` over the collected `matches`/`indices`. — *Why*: Fixes all five affected commands at the shared seam without touching search/list semantics. — *Rejected*: A new `--id` selector flag (adds CLI surface); changing `Match` (alters `list`/search public contract). + +## Tasks + +### Phase 2: Core Implementation + +- [x] T001 Add an exact-ID precedence pass in `RequireSingle` (`src/internal/idea/idea.go`): before the `len(matches) > 1` ambiguity branch returns, scan `matches` with `strings.EqualFold(m.ID, query)`; if exactly one is an exact-ID hit, return that idea with its original index from `indices`; otherwise fall through to the existing logic unchanged. + +### Phase 3: Integration & Edge Cases + +- [x] T002 Add regression test `TestRequireSingle_ExactIDBeatsSubstring` to `src/internal/idea/idea_test.go` following the table-driven `TestRequireSingle_*` convention: GIVEN idea[0] `{ID:"jznd", Text:"the idea to edit"}` and idea[1] `{ID:"qg64", Text:"see related [jznd] for context"}` (both open, both match `"jznd"`), WHEN `RequireSingle("jznd", ideas, FilterAll)`, THEN returns idea[0] at index 0 with no error. + +## Acceptance + +### Functional Completeness + +- [ ] A-001 R1: `RequireSingle` returns the exact-ID owner (idea[0], index 0, no error) when one exact-ID hit coexists with substring-only matches. +- [ ] A-002 R2: Exact-ID equality uses `strings.EqualFold(match.ID, query)` (case-insensitive, no new dependency). +- [ ] A-003 R3: Zero or >1 exact-ID hits fall through to existing logic (single match returns; >1 returns the "Multiple matches" error with no silent pick). +- [ ] A-004 R4: The precedence pass iterates the post-`matchesFilter` match set, so filtered-out exact-ID ideas are never force-selected. + +### Behavioral Correctness + +- [ ] A-005 R1: The input that previously aborted with "Multiple matches" (`"jznd"` against the cross-reference fixture) now resolves to idea[0]; all other inputs behave identically (`TestRequireSingle_OneMatch`, `_NoMatch`, `_MultipleMatches` still pass). + +### Scenario Coverage + +- [ ] A-006 R1: `TestRequireSingle_ExactIDBeatsSubstring` exists, is table-driven per the project convention, and passes. + +### Edge Cases & Error Handling + +- [ ] A-007 R3: The defensive `exactCount > 1` case is not silently resolved — it falls through to the ambiguity error (covered by the unchanged `_MultipleMatches` path and code inspection; IDs are unique per Principle VI so this is defensive). + +### Code Quality + +- [ ] A-008 Pattern consistency: New code follows the naming and structural patterns of the surrounding `RequireSingle` body and the table-driven test convention. +- [ ] A-009 No unnecessary duplication: Reuses `strings.EqualFold` and the already-collected `matches`/`indices`; no new helper or dependency. +- [ ] A-010 No magic strings/numbers: No new magic strings or numbers introduced. +- [ ] A-011 Logic stays in `internal/idea` (Constitution IV): The fix lives in `internal/idea`, not `cmd/`. + +## Notes + +- Check items as you review: `- [x]` +- All acceptance items must pass before `/fab-continue` (hydrate) + +## Assumptions + +| # | Grade | Decision | Rationale | Scores | +|---|-------|----------|-----------|--------| +| 1 | Certain | Exact-ID precedence lives only in `RequireSingle`; `Match`/`FindAll` untouched | Pre-agreed (intake option 1); Principle VI makes search/output a public contract | S:95 R:80 A:90 D:95 | +| 2 | Certain | Exact-ID equality is case-insensitive via `strings.EqualFold(m.ID, query)` | Matches existing case-insensitive `Match`; IDs are lowercase by Principle VI; no new dependency | S:90 R:85 A:90 D:90 | +| 3 | Certain | `exactCount > 1` falls through to the existing "Multiple matches" error (no silent pick) | Explicitly specified; Principle VI guarantees unique IDs so this is defensive | S:95 R:90 A:95 D:95 | +| 4 | Certain | Precedence scan iterates `matches` (post-`matchesFilter`), preserving filter semantics | Explicitly specified; iterating `matches` achieves this naturally | S:95 R:85 A:90 D:95 | +| 5 | Certain | Regression test uses `FilterAll` (both ideas open) | Prevailing `TestRequireSingle_*` convention uses `FilterAll`; intake permits either as long as both fixtures pass the filter | S:90 R:80 A:90 D:90 | + +5 assumptions (5 certain, 0 confident, 0 tentative). diff --git a/src/internal/idea/idea.go b/src/internal/idea/idea.go index d1e351e..ec63cb5 100644 --- a/src/internal/idea/idea.go +++ b/src/internal/idea/idea.go @@ -527,6 +527,19 @@ func RequireSingle(query string, ideas []Idea, filter FilterKind) (Idea, int, er return Idea{}, -1, fmt.Errorf("No idea matching '%s'", query) } if len(matches) > 1 { + // Exact-ID precedence: if exactly one matched idea's ID equals the + // query (case-insensitive), it wins over incidental substring matches. + exactIdx := -1 + exactCount := 0 + for j, m := range matches { + if strings.EqualFold(m.ID, query) { + exactIdx = j + exactCount++ + } + } + if exactCount == 1 { + return matches[exactIdx], indices[exactIdx], nil + } var lines []string for _, m := range matches { lines = append(lines, fmt.Sprintf(" %s", FormatLine(m))) diff --git a/src/internal/idea/idea_test.go b/src/internal/idea/idea_test.go index ee64bea..1c8863c 100644 --- a/src/internal/idea/idea_test.go +++ b/src/internal/idea/idea_test.go @@ -222,6 +222,51 @@ func TestRequireSingle_MultipleMatches(t *testing.T) { } } +func TestRequireSingle_ExactIDBeatsSubstring(t *testing.T) { + tests := []struct { + name string + query string + ideas []Idea + wantID string + wantIdx int + }{ + { + name: "exact id wins over substring in another idea's text", + query: "jznd", + ideas: []Idea{ + {ID: "jznd", Text: "the idea to edit"}, + {ID: "qg64", Text: "see related [jznd] for context"}, + }, + wantID: "jznd", + wantIdx: 0, + }, + { + name: "uppercase query matches lowercase id case-insensitively over substring", + query: "JZND", + ideas: []Idea{ + {ID: "jznd", Text: "the idea to edit"}, + {ID: "qg64", Text: "see related [JZND] for context"}, + }, + wantID: "jznd", + wantIdx: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i, idx, err := RequireSingle(tt.query, tt.ideas, FilterAll) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if i.ID != tt.wantID { + t.Errorf("ID = %q, want %q", i.ID, tt.wantID) + } + if idx != tt.wantIdx { + t.Errorf("idx = %d, want %d", idx, tt.wantIdx) + } + }) + } +} + // --- File Operations Tests --- func writeBacklog(t *testing.T, dir, content string) string {