diff --git a/changes/search-client-recovery.fixed.md b/changes/search-client-recovery.fixed.md new file mode 100644 index 00000000..f214bdc4 --- /dev/null +++ b/changes/search-client-recovery.fixed.md @@ -0,0 +1,6 @@ +--- +"githits": patch +"@githits/mcp": patch +--- + +- **Search recovery and target guidance** - Correct terminal target recovery and canonical package addressing, and unify CLI/MCP text around compact target-state output with inline recovery and concrete documentation provenance. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index e29563cd..a991660c 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -226,34 +226,34 @@ Unified search spans indexed dependency and repository code, docs, and explicit **Decision guide.** Use `githits example` for canonical cross-project examples. Use `githits search` for indexed dependency/repository search. Use `githits search --source symbol` when you want symbol-shaped unified search. -**Targets.** `--in ` is repeatable and required. Package targets require explicit `registry:name[@version]` (for example `npm:express`, `pypi:requests@2.32.3`). Repo targets use `github:org/repo[#ref|@ref]`, `github.com/org/repo[#ref|@ref]`, or `https://github.com/org/repo[#ref|@ref]`; omitted refs request the backend default-branch intent. Exact standalone documentation sites use `site:` and normally pair with `--source docs`. User-facing output canonicalizes repo targets as `github:org/repo#ref` so refs can contain `@` safely. Exact duplicate targets are deduplicated while preserving order. Mixing target kinds in the same request is supported. +**Targets.** `--in ` is repeatable and required. Package targets require explicit `registry:name[@version]` (for example `npm:express`, `pypi:requests@2.32.3`) and inspect an indexed artifact/manifest root. Swift package targets use `swift:github.com//`; Zig package targets use `zig:gh//`. Use public GitHub repository targets for full repositories or sibling packages. Repo targets use `github:org/repo[#ref|@ref]`, `github.com/org/repo[#ref|@ref]`, or `https://github.com/org/repo[#ref|@ref]`; omitted refs request the backend default-branch intent. Exact standalone documentation sites use `site:` and normally pair with `--source docs`. User-facing output canonicalizes repo targets as `github:org/repo#ref` so refs can contain `@` safely. Exact duplicate targets are deduplicated while preserving order. Mixing target kinds in the same request is supported. **Sources and filters.** `--source docs|code|symbol` restricts results to one evidence type; omit it to let GitHits select the best sources. Use `--source symbol` when you want symbol-shaped search results. `--category` is the broad filter (`callable`, `type`, `module`, `data`, `documentation`); `--kind` is the precise taxonomy. `--path-prefix`, `--intent`, and `--public` narrow the result set further. For docs-only search, code/symbol-only filters (`category`, `kind`, `file_intent`, `public_only`) are ignored client-side because the backend docs source rejects them. `--name` and `--lang` compile into query qualifiers instead of becoming separate backend fields. **Intent filter.** When `--intent` is omitted, unified search sends no file-intent filter. Pass `--intent production` or another specific intent only when you want to narrow the result set. Some sources can still ignore `fileIntent`; when they do, the JSON `sourceStatus` block and terminal notes report that explicitly. -**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. The rendered `search-status` action is the concise way to continue; reissuing the same search is also valid and waits on the same underlying work. Ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). +**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. The rendered `search-status` action is the concise way to continue; reissuing the same search is also valid and waits on the same underlying work. Ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the reference; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI keeps unknown lifecycle output conservative and does not poll an unrecognized reference. A missing or ambiguous standalone site can instead return target-local recovery without a reference; callers can retry an explicit suggested site label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). +**Terminal target recovery and provenance.** The CLI renders one target-state list. Exact `NOT_FOUND` and `UNRESOLVABLE` source states become readable lane-specific reasons such as `package not found: code`, `version unavailable: code`, or `repository ref unresolved: code`. If the target also has searched or indexing evidence, the reason is bare (`not found: symbols`) and coordinate recovery is omitted. A target with no searched/indexing lane gets at most one inline `Fix:` or replayable `Try:` line; package guidance points to its public GitHub repository for full-repository or sibling-package evidence. Site suggestions and indexed alternatives stay on the affected target, and package refs remain informational rather than replayable package targets. Completed-empty and terminal site suggestions remain `Try:`-eligible even when the site lane was searched empty. `SYMBOL` readiness is presented as `symbols`, separately from `code`. Target-owned constraints stay inline with their lane; query-wide warnings and unowned source constraints remain one global block. Structured JSON keeps source-status, target-resolution, warning, and hit values unchanged. The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Breakdown labels use `repo code hit(s)`, `repo symbol(s)`, `repo doc(s)`, and `docs page(s)`. Hits remain numbered and preserve follow-up locators in compact human form: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search | / target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row: code and symbols use compact lane names, while documentation contributors retain canonical `site:` and `github:/#` locators. A source identical to its standalone target is written once; a sole pinned repository source replaces its less-specific ref-less repository target, while an already-pinned target remains beside its resolved commit. Compact repository provenance requires both the repository URL and commit. Documentation without concrete provenance stays in detailed target-state form. Any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. The representative CLI n8n active-empty output shape is: ```text -Indexing - no results yet +No results yet | indexing | 0/1 ready -- npm:n8n -> 2.36.7 - Indexing: code, repository docs | Available now: n8n.io docs ( pages; - capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master +- npm:n8n + indexing: code, repository docs; available: n8n.io docs ( pages; capped); + indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master -Search | 0/1 target ready Next: githits search-status --wait 20 ``` -**Highlighting and width.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, target details remain plain, and the optional session row is dim. Color never carries meaning or changes wording. CLI target details and hit summaries wrap to the current terminal width; MCP uses the shared 80-column fallback. +**Highlighting and width.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, and target details remain plain. Color never carries meaning or changes wording. CLI target details and hit summaries wrap to the current terminal width; MCP uses the shared 80-column fallback. -**Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups structured readiness and trust facts under each target, including searched, waiting, unavailable, stale, provisional, and capped coverage. Exact requested/fresh/served divergence appears once only when identities differ. Raw reason codes, indexing references, promoted duplicate warnings, opaque evidence prose, and the exact `evidenceNotice` remain in JSON. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable next action. +**Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups structured readiness and trust facts under each target, including searched, waiting, unavailable, stale, provisional, and capped coverage. Exact requested/fresh/served divergence appears once only when identities differ; `using:` omits the arrow when it already explains the served snapshot. Raw reason codes, indexing references, promoted duplicate warnings, opaque evidence prose, and the exact `evidenceNotice` remain in JSON. Constraint and warning text retains separate raw lane and target provenance, such as `ignored filter (docs): fileIntent`; known lanes are lowercased and unknown non-empty lanes pass through lowercased. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable target-local or global action. Hits remain separate from target-state diagnostics. Contributor-bearing rows omit redundant pair-level `resultCount`, pair-level `coverage`, and healthy resolution metadata from the compact JSON projection. Other source-status signals remain unchanged: ignored / incompatible filters and query features, terminal indexing notes, promoted freshness warnings, and ordered standalone-site recovery targets. Site suggestions come from `suggestedSiteTargets`; the exact `suggestedSiteTargetsTruncated` Boolean is retained whenever suggestions are present. They are advisory labels to retry explicitly, not aliases, and the client never selects or retries one automatically. @@ -266,7 +266,7 @@ githits search-status ref_abc123 githits search-status ref_abc123 --json ``` -Follow-up for a prior unified search. Use the `searchRef` only when `githits search` emits the explicit action, including when the initial request could not complete inside the wait window or a completed result carries an evidence notice. Before completion, `search-status` can return an atomic interim result when every runnable target/source pair is serveable; if the original request used `--allow-partial`, it can instead return a serveable subset while other pairs remain unavailable. +Follow-up for a prior unified search. Use the `searchRef` only when `githits search` emits the explicit action, including when the initial request could not complete inside the wait window or a completed result carries an evidence notice. Before completion, `search-status` can return an atomic interim result when every runnable target/source pair is serveable; if the original request used `--allow-partial`, it can instead return a serveable subset while other pairs remain unavailable. Its human output uses the same single target-state list as `search`: the headline carries lifecycle/readiness, target-local state and recovery stay with each identity, hits remain separate, and one final continuation can follow. `PENDING`, `INDEXING`, and `SEARCHING` are active incomplete states and can be checked again with the same reference. `DEFERRED` is terminal even though JSON keeps `completed: false`: the session has stopped following lifecycle work, any stored `result` and progress remain usable, and a later `search` starts a fresh session when needed. `TIMEOUT` and `FAILED` are also terminal. @@ -275,7 +275,8 @@ Follow-up for a prior unified search. Use the `searchRef` only when `githits sea With `includeResults: true`, the stored `result` retains the same documentation contributors and `evidenceNotice` as the initial search result. The CLI uses the same projection and documentation-source formatter for both commands; contributors are not -duplicated onto generic progress targets. +duplicated onto generic progress targets. JSON remains the stable, lossless +follow-up contract even when text collapses healthy sources or groups recovery inline. ### `githits languages` @@ -716,7 +717,7 @@ Lists files in an indexed dependency. `[spec] [path-prefix]` positionals mirror **`stdout` vs `stderr` routing (plain mode).** Truncation warnings (`More files available — pass --limit higher …`) and empty-result hints go to **stderr**, not stdout, so they stay visible to humans without polluting pipes. In `--verbose` the same text renders inline. -**Addressing ambiguity guard.** In `--repo-url` mode, a positional that matches a known registry prefix (`npm:`, `pypi:`, `hex:`, `crates:`, `nuget:`, `maven:`, `zig:`, `vcpkg:`, `packagist:`) is rejected with a "looks like a package spec" error — catches `code files npm:express --repo-url …` typos that would otherwise silently interpret the spec as a path prefix. +**Addressing ambiguity guard.** In `--repo-url` mode, a positional that matches a known registry prefix (`npm:`, `pypi:`, `hex:`, `crates:`, `nuget:`, `maven:`, `zig:`, `vcpkg:`, `packagist:`, `rubygems:`, `go:`, `swift:`) is rejected with a "looks like a package spec" error — catches `code files npm:express --repo-url …` typos that would otherwise silently interpret the spec as a path prefix. This list mirrors `PKGSEER_REGISTRY_ARGS`. **Exit codes.** `0` on success (including empty results — absence of files is not an error). `1` on error (authentication, indexing, invalid arguments, backend failures). diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 00dea1b9..bb97a332 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -241,23 +241,28 @@ readiness, trust limits, and action selection; the text renderer owns wording, wrapping, hit anatomy, and ordering. Callers provide ANSI enablement, surface-native action syntax, and an optional output width. CLI supplies its current terminal width; MCP uses the formatter's 80-column default. The order is -outcome headline, target blocks with -identity plus grouped readiness/usable alternatives, warnings and results, an -optional session summary, and one positive next action. +an outcome headline carrying count/breakdown, lifecycle, readiness, and +pagination when applicable; one compact `Sources: - ` row for +ordinary completed current results, with canonical site locators and compact +GitHub revision locators; a source identical to its standalone target is written once. +A sole pinned repository source replaces its less-specific repository target. +An already-pinned repository target remains beside its resolved commit. Compact repository +provenance requires both its URL and commit; documentation without concrete provenance uses +one detailed target block instead. Other non-compact results likewise use one block per +requested target; target-local state/recovery and global warnings; the separate ranked hit +list; and at most one final `Next:` action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct. Active empty output uses -`Indexing - no results yet`; an active response without a snapshot uses -`Indexing - no result snapshot yet`, with corresponding lifecycle labels for -other active states. Active result counts use `interim` when `partialResults` is -false and `partial` when it is true. When session facts exist, the renderer may -emit one optional session row composed from available facts: `Search ` when -a reference exists, aggregate `/ target(s) ready` when progress -exists, and a lifecycle summary when a reference has no progress. The combined -form is `Search | / target(s) ready`; completed output -without session facts may omit it. A reference appears once in that row when -available and once in the follow-up action when the action carries it. Terminal -and unknown statuses retain their exact status. Site suggestions remain ordered -advisory labels and are never selected automatically. +`No results yet | indexing | 0/1 ready`; an active response without a snapshot +uses `No result snapshot yet | indexing | 0/1 ready`, with corresponding +lower-case lifecycle labels for other active states. Active result counts use +`interim` when `partialResults` is false and `partial` when it is true. Terminal +or unknown progress retains lifecycle/readiness in the headline, while completed +output omits them. Target rows keep deterministic `using`, `searched`, +`indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments; +exact terminal reasons remain lane-readable, and a target gets at most one +inline `Fix:` or replayable `Try:` line. Site suggestions and indexed alternatives +remain attached to their target and are never selected automatically. `evidenceNotice` remains exact in JSON and is not rendered as a generic mutable-evidence slogan. Concrete stale, provisional, pending, and coverage @@ -268,9 +273,10 @@ Reissuing the same search is valid and waits on the same underlying work; text does not emit negative repeat or poll policy directives. MCP renders `Next: search_status search_ref=... wait_timeout_ms=...`; CLI renders -`Next: githits search-status ... --wait ...`. The session row and continuation -action use the same reference when both are present; raw diagnostic fields are -never rendered. Search-result follow-ups likewise use +`Next: githits search-status ... --wait ...`. An active continuation reference +appears exactly once, in that surface-native final `Next:` action; stopped terminal +references are not rendered. Raw diagnostic fields are never rendered. +Search-result follow-ups likewise use `code_read` / `docs_read` in MCP and `githits code read` / `githits docs read` in CLI. ANSI-stripped CLI output shares the same hierarchy and wording as no-color MCP text apart from those supplied command dialects; line breaks can differ diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 70ff3c1a..19f9cc66 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -143,28 +143,54 @@ Treat failures as live backend or contract findings, not deterministic unit-test **Promoted `warnings[]`.** Noteworthy `sourceStatus` entries — sources reporting `incompatibleQueryFeatures`, `ignoredQueryFeatures`, `incompatibleFilters`, `ignoredFilters`, lifecycle anomalies (`indexingStatus`, `codeIndexState`), or a free-form `note` — are also surfaced as a top-level `warnings: string[]` in the completed/incomplete payloads (and appended after parser warnings inside the `search_status` result block). The structured detail still lives in `sourceStatus`; `warnings[]` is the agent-visible signal that something about execution did not match the request. On completed empty results, healthy non-contributor source entries are also retained with zero `resultCount` and served identity; requested/fresh labels emit only when they materially differ from served. Contributor-bearing DOCS rows retain their physical contributors instead of duplicating healthy served/current resolution metadata. Healthy `INDEXED` / `CURRENT` / non-divergent `STALE` states never become warnings. `PROVISIONAL` is queryable but remains a visible non-healthy indexing signal, including on completed responses. Successful non-empty responses keep the prior compact projection. JSON keeps promoted warnings and source-status detail lossless; MCP text classifies parser/query and structured constraint facts once below the outcome and does not repeat promoted lifecycle/freshness warning prose or opaque notes. Implementation in `buildSourceStatusWarnings` and empty-result compaction (`packages/mcp/src/shared/unified-search-response.ts`). -**Standalone-site recovery.** `search` accepts exact documentation targets as `site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels are preserved in order for missing or ambiguous sites, together with the exact `suggestedSiteTargetsTruncated` Boolean. The compact source-status row becomes actionable even when it has no note or lifecycle warning, and MCP text-v1 renders replayable target labels plus an omitted-candidates notice when truncated. Suggestions are advisory rather than aliases: active known sessions keep polling their current `searchRef`, while completed or terminal recovery can expose one explicit site-retry action without selecting a label automatically. Terminal missing or ambiguous results can omit `searchRef` and instead expose recovery guidance. - -**Documentation sources.** DOCS `sourceStatus` rows retain bounded physical -`contributors` and coverage in JSON. Text places the user-meaningful readiness -state under its target, using `Indexing`, `Searched`, `Available now`, `Unavailable`, -`Using`, or `Status` details as applicable. `Status` appears only when the -backend supplies an explicit current, pending, indexing, provisional, or stale -target state; session activity alone does not invent target state. Site identity, -stale/provisional qualifiers, and partial or capped coverage remain attached to -that target; internal reason codes and indexing references stay in JSON. -Partial/capped coverage is published evidence, not a progress or retry signal. - -`evidenceNotice` is carried once on initial and stored result envelopes. JSON -retains that exact backend-owned notice; default text does not render it or replace -it with a generic mutable-evidence slogan. Instead, concrete stale, provisional, -pending, and coverage facts remain grouped under the affected target. A -`searchRef` is actionable only when rendered output supplies a status follow-up. -Reissuing the same search is valid and waits on the same underlying work. Terminal -status and unknown-status handling remains conservative, while -`search_status(includeResults: true)` uses the same result projection and -formatter—contributors are never copied onto generic progress targets, and -`allowPartialResults` retains its separate pair-omission meaning. +**Standalone-site recovery.** `search` accepts exact documentation targets as +`site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels +are preserved in order, with the exact truncation Boolean. The shared text +projection keeps them in the target group: an active suggestion-only target shows +`available:`, while a completed-empty or terminal target can show one replayable +`Try:` line with the remaining suggestions summarized there. Suggestions are +advisory rather than aliases; the client never selects or retries one automatically. + +**Unified target-state output.** MCP `search` and `search_status` text-v1 return one +outcome-first response. The headline carries result count/type breakdown, +active/terminal lifecycle, readiness, and pagination when applicable. A completed +current result set collapses to one `Sources: - ` row; code and +symbols use lane names while documentation uses a canonical `site:` +or `github:/#` locator. A source identical to its standalone +target is written once; a sole pinned repository source replaces its less-specific +ref-less repository target, while an already-pinned target remains beside its resolved +commit. Compact repository provenance requires both the repository URL and commit. +Documentation without concrete provenance stays in detailed target-state form. Any trust, +warning, alternative, suggestion, or non-current fact keeps every target in one +detailed list. Each target row can contain `using`, `searched`, `indexing`, an exact +terminal reason, `available`, `indexed`, constraints, and at most one inline +`Fix:`/`Try:` recovery line. Completed-empty and terminal site suggestions remain +`Try:`-eligible even when the site lane was searched empty. Detailed lane order is +`code`, `symbols`, `repository docs`, concrete site docs, then docs. Hits remain a +separate numbered ranked list. + +Exact `NOT_FOUND` and `UNRESOLVABLE` reasons are client-owned and lane-specific: +`package not found: code`, `version unavailable: code`, or +`repository ref unresolved: code`, for example. If the same target has searched or +indexing evidence, the reason becomes bare (`not found: symbols`) and no coordinate +recovery is suggested. Unknown unavailable states remain `unavailable`; backend +notes and reason enums are not copied into text. Query-wide warnings remain one +global `Warnings:` block after target rows and before hits; target-owned constraints +stay in their target row and unowned source constraints remain global. + +There is at most one final `Next:` line. Active continuation uses the supplied +`searchRef` exactly once in the executable `search_status` action; there is no +separate session row. MCP renders +`Next: search_status search_ref="..." wait_timeout_ms=20000`. A target-local +`Fix:`/`Try:` never suppresses an active poll or completed evidence-status action, +but suppresses generic rerun/query-rewrite guidance. Terminal and unknown sessions +do not poll their stopped reference. Reissuing the same search remains valid. + +JSON remains the lossless stable boundary: `sourceStatus`, warnings, target +resolution, evidence notices, and hit metadata are retained there even when text +collapses them. `search_status` uses the same projection for stored results; +contributors are not copied onto generic progress targets, and +`allowPartialResults` keeps its separate pair-omission meaning. ### `pkg_info` response shape @@ -309,45 +335,49 @@ The `hint` field is emitted only when the cap *actually truncated* the response **Unified search outcome-first anatomy** (CLI human search/search-status and MCP `search` / `search_status` text-v1). One shared presentation model owns target groups and trust facts; one shared text renderer owns wording, wrapping, hit -anatomy, and ordering. Callers supply only ANSI enablement and surface-native -action syntax. The order is: - -1. outcome headline; -2. one compact `Sources:` row for ordinary completed current results, or target blocks with identity plus grouped readiness and usable alternatives when trust facts require them; -3. warnings and results; -4. an optional session summary; and -5. one positive next action, when applicable. - -Active lifecycle labels remain `Preparing`, `Indexing`, and `Searching` for -`PENDING`, `INDEXING`, and `SEARCHING`. The exact active empty wording is -`Indexing - no results yet`; when no snapshot exists it is -`Indexing - no result snapshot yet`, with the corresponding lifecycle label for -other active states. Active hits are labelled `interim` when `partialResults` is -false and `partial` when it is true. Progress-only responses show only derivable -target readiness and alternatives; they never synthesize source or contributor -facts. - -When session facts exist, text may include one optional session row composed from -the facts available: `Search ` when a reference exists, aggregate -`/ target(s) ready` when progress exists, and a lifecycle summary -when a reference has no progress. The combined form is -`Search | / target(s) ready`; completed output without -session facts may omit the row. A reference appears once in that row when -available and once in the follow-up action when the action carries it; raw -diagnostic fields are not rendered. MCP renders -`Next: search_status search_ref="..." wait_timeout_ms=20000`; CLI renders -`Next: githits search-status ... --wait 20`. Text emits no negative repeat or poll -policy directive: reissuing the same search is valid and waits on the same -underlying work. Suggested site targets retain backend order and an omitted- -candidates signal, but remain advisory labels rather than automatic retries. - -`evidenceNotice` stays exact in JSON and is not rendered in default text. The -renderer keeps concrete stale, provisional, pending, and capped-coverage facts -under their target, while raw reason codes, indexing references, promoted -duplicate warnings, and opaque evidence prose remain in JSON. Query/filter and -structured-constraint facts appear once below the outcome. Surface-native pivots -name `source="symbol"` / `code_grep` in MCP and `--source symbol` / -`githits code grep` in CLI. +anatomy, and ordering. The order is: + +1. one outcome headline with count/breakdown, lifecycle, readiness, and + pagination when applicable; +2. one compact `Sources: - ` row for ordinary completed current + results, retaining concrete documentation provenance when available, or one + detailed block per target when any state must remain visible; +3. target-local state and recovery, then query-wide warnings; +4. the separate numbered ranked hit list; and +5. at most one session/query-wide `Next:` action. + +Active empty headlines are `No results yet | indexing | 0/1 ready` and +`No result snapshot yet | indexing | 0/1 ready` (with `preparing` or `searching` +for the other active states). Active results say `partial` when +`partialResults` is true and `interim` when it is false. Terminal or unknown +progress retains its lower-case lifecycle and readiness; completed output omits +those fields. Progress-only responses show only derivable target identity and +lane-free freshness; they never invent source or contributor facts. + +Detailed target rows keep one identity and deterministic segment order: +`using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, +then target-scoped constraints. Lanes are `code`, `symbols`, `repository docs`, +concrete site docs, and docs. Exact terminal states use readable client-owned +reasons (`package not found`, `version unavailable`, or `repository ref +unresolved`) plus their lane. When searched or indexing evidence exists for the +same target, the reason is bare and no coordinate recovery is suggested. A +target gets at most one inline `Fix:` or replayable `Try:` line. Stale, +provisional, and coverage facts qualify the target/source rather than creating a +second list. Query-wide warnings remain one `Warnings:` block after target rows +and before hits; target-owned constraints stay in their row. + +There is no separate session row. An active or evidence-status continuation uses +the supplied `searchRef` exactly once in the executable `Next:` action: +`Next: search_status search_ref="..." wait_timeout_ms=20000` for MCP or +`Next: githits search-status ... --wait 20` for CLI. Target-local recovery never +suppresses an active poll or completed evidence-status action, but suppresses a +generic rerun/query rewrite. Stopped terminal references are not polled. + +`evidenceNotice` stays exact in JSON and is not rendered in default text. JSON is +the lossless stable boundary for source statuses, target resolution, warnings, +evidence notices, and hit metadata; text remains a compact decision surface. +Surface-native pivots name `source="symbol"` / `code_grep` in MCP and +`--source symbol` / `githits code grep` in CLI. The representative CLI n8n example is maintained in `docs/implementation/cli-commands.md` as the output source of truth. @@ -392,20 +422,16 @@ and `docs page(s)`. When more results exist without a next offset, the final fie **Follow-up — crawled-doc section anchors.** Unified search can label a crawled documentation hit with a matching section title while returning only its page ID. Without a line anchor, `docs_read` must start at the beginning of the page. Carrying section ranges through search results requires backend/search-location support and is outside the CLI response-formatting slice. -Completed empty search uses the model's applicable action: generic query pivots are -suppressed for evidence-limited or unsearched sources, which instead direct the -caller to rerun the search later. Indexing/provisional evidence prefers waiting -or an indexed alternative, standalone site searches expose only a -shorter/broader site query, and filter removal or symbol/code-grep pivots appear -only when applicable. Surface-native pivots name -`source="symbol"` / `code_grep` in MCP and `--source symbol` / -`githits code grep` in CLI. A result with both an evidence notice and -`searchRef` emits one status continuation. Terminal `DEFERRED`, `FAILED`, and -`TIMEOUT` preserve disclosed evidence and their lifecycle state; unknown statuses -preserve the raw value without inferred semantics. Promoted lifecycle/freshness -warning prose, opaque evidence text, and the exact notice remain in JSON but are -not repeated in default text; parser/query and structured constraint facts appear -once below the outcome. +Completed-empty action selection is target-aware: exact terminal lanes with no +searched/indexing peer get local recovery, while searched-empty evidence can get +a query rewrite. Indexing or trust-only evidence gets a single rerun action when +no target-local recovery exists; standalone-site output uses its site-specific +rewrite only when applicable. Filter removal and symbol/code-grep pivots appear +only when requested constraints make them useful. Terminal `DEFERRED`, `FAILED`, +and `TIMEOUT` preserve disclosed evidence and lifecycle; unknown statuses stay +conservative and do not poll. Promoted lifecycle/freshness prose, opaque evidence +text, and the exact notice remain in JSON; parser/query and target-owned +constraint facts appear once in text. **Listing anatomy** (`code_files` text-v1): diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md new file mode 100644 index 00000000..2b157984 --- /dev/null +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -0,0 +1,1093 @@ +# Plan: Search client recovery and target guidance + +## Status + +- Overall: Phase 1 COMPLETE; Phase 1B COMPLETE; Phase 2 remains BLOCKED +- Current phase: Phase 2 — terminal backend failure details (blocked) +- Later phase: Phase 2 — terminal backend failure details (BLOCKED on private backend #2133) +- Commits: Phase 1 runtime `56f6003`; Phase 1 guidance/docs `e0057e6`; runtime/preflight + fixes `c88194b`, `80f93a2`; privacy/wording review closure `b6c0581`; Phase 1B + plan baseline `28772a3`; Phase 1B runtime `9b3523e` and runtime closure commits + `6b4a2d2`, `c268cba`, `e4cb960`, `49a6f23`, `2c0c9e2`; compact provenance + follow-up commits `9364f81`, `bc51e67`, `c423ff5`, and `dcbb9f9`. +- Phase 1B worker evidence: 144 focused presentation/text/status tests pass; 218 + consumer, parity, CLI/MCP, and smoke-helper tests pass; `bun run typecheck` and + the owned-file Biome check pass. One transient worker test-suite deletion mistake + was restored before verification. The original worker lost authentication and was + replaced; the replacement resumed the existing delta without discarding it. +- Final-head validation after runtime closure commit `49a6f23` is complete: `bun test` + passed 3,564 tests with 11,377 expectations and 0 failures across 185 files; + typecheck, format/lint, root and MCP builds, and both package validations passed. + All four smoke modes passed: CLI live (89 steps), MCP live (46), built CLI (18), + and built MCP (7). The MCP publish dry-run was skipped only because version 0.11.2 + is already published. Targeted Claude and Codex agent evaluation passed 4/4 with + high confidence and no futile `search_status` polling; it ran before the final + runtime closure commits, whose deterministic shapes are covered by final-head tests + and smoke suites. +- The original fresh Opus review loop is complete and externally clean. The compact + provenance follow-up used the maximum three Opus rounds; round 3 found one pinned-target + identity defect, closed in `dcbb9f9`. A final internal changed-delta review + found no remaining issue; no fourth external round was run. +- Last verified: 2026-08-31 + +## Problem and expected outcome + +The unified search backend already returns enough structured state for the client to +distinguish invalid or unresolvable targets, symbol results, and the lane that ignored +a filter. The shared CLI/MCP text projection discards those distinctions: + +- `NOT_FOUND` and `UNRESOLVABLE` sources become `Next: rerun search later`, which + sends callers into a futile retry loop; +- `SYMBOL` readiness is presented as `code`, so a symbol-only search can look as if + the wrong lane ran; and +- ignored-filter warnings retain the target but lose the source lane, so a docs-lane + warning can appear to describe the whole target. + +Separately, all public target guidance describes package coordinates as generic +`registry:name[@version]`. That is incomplete for Swift and Zig, whose verified +canonical identities are repository-shaped package names. It also does not explain +that package targets are artifact/manifest-root scoped rather than full-repository +searches. + +When this plan is complete: + +- terminal target failures tell CLI and MCP text callers to verify or replace the + target without advising an unchanged retry; +- symbol readiness is labelled as symbols, independently from code readiness; +- ignored-filter text identifies both the lane and target; +- search help, schemas, stable MCP guidance, and public Agent Skill guidance include + verified Swift/Zig forms and the package-versus-repository scope boundary; and +- CLI and MCP text render one compact target-state representation in which searched, + indexing, terminal, stale/provisional, alternative, suggestion, and target-warning + facts stay attached to their target, while only session-wide continuation remains + global; and +- once the backend exposes typed terminal failure metadata, `FAILED` search sessions + display an actionable cause without rendering opaque backend prose. + +## Verified current state + +### Client behavior + +- `packages/mcp/src/shared/unified-search-presentation.ts` owns the projection used by + both CLI and MCP text. Phase 1B runtime commit `9b3523e` maps `source === "symbol"` + to `symbols`, retains target-grouped lane/provenance facts, derives exact terminal + reasons, and attaches at most one typed target-local recovery value per group. +- `packages/mcp/src/shared/unified-search-text.ts` renders one outcome-first target + list. Healthy completed results collapse to one `Sources:` row; otherwise each + target carries its transient, terminal, trust, warning, alternative, and inline + `Fix:`/`Try:` facts. Only one session/query-wide `Next:` remains, with an active + `searchRef` appearing exactly once there; JSON remains unchanged. +- `packages/mcp/src/shared/unified-search-response.ts` preserves source statuses and + lane-aware warning information in JSON. The defect is confined to text projection; + the success JSON envelope does not need a compatibility change in Phase 1. +- Focused unit, CLI, MCP, parity, and smoke-helper tests cover terminal recovery, + distinct symbol readiness, lane-aware warnings, target grouping, compact sources, + lifecycle headlines, and exactly-once continuation. Final-head full-suite, build, + smoke, package, and targeted agent-evaluation evidence is recorded in the Phase 1B + verification record; the fresh Opus review loop is complete with all accepted + findings closed. +- `packages/mcp/src/shared/package-spec.ts` validates registry and syntax only. It + deliberately does not own backend package identity conventions. + +### Target guidance + +Canonical target guidance was incomplete across these user-facing boundaries; the +owned guidance surfaces below now carry the verified forms and scope boundary: + +- the CLI search description and `--in` option in `src/commands/search.ts`; +- the MCP search target schema in `packages/mcp/src/tools/search.ts`; +- the code-navigation target schema in + `packages/mcp/src/tools/code-navigation-shared.ts`; +- the stable MCP quick-start preamble in + `packages/mcp/src/mcp/instructions.ts` and its exact public skill copy in + `skills/githits-mcp/SKILL.md`; +- `skills/githits-code/references/code-and-docs.md`; +- the target syntax in `docs/implementation/cli-commands.md`; and +- the text/search scope contracts in `docs/implementation/tools.md`, which need the + missing package-scope and recovery rules rather than replacement syntax wording. + +Live smoke evidence established these canonical forms: + +- Swift: `swift:github.com//`; +- Zig: `zig:gh//`. + +Short forms such as `swift:vapor`, `swift:vapor/vapor`, and +`zig:zigzap/zap` returned `NOT_FOUND`. Client-side alias normalization would be +guesswork and would conflict with backend canonical identity ownership. + +### Backend issue boundary + +This public githits-cli plan refers to backend work only by private backend issue +number; it does not publish the private repository name or URL. + +The backend defects have been filed with runnable repros: + +- private backend #2128 — exact + `name:` qualifiers remove valid symbol matches; +- private backend #2129 — standalone + site path prefixes are ignored; +- private backend #2130 — symbol-only + searches can complete empty while symbols are still indexing; +- private backend #2131 — repository + locators can leak a foreign package identity; +- private backend #2132 — identical + code ranges can occupy separate result slots; and +- private backend #2133 — terminal + search sessions expose no actionable failure metadata. + +The Zod hosted-doc duplicate repro was added to private backend #2123. +The CLI must not locally deduplicate results, repair locators, reinterpret site scope, +or synthesize symbol indexing state for these backend-owned defects. + +## Assumptions and decisions + +1. Phase 1 handles only exact verified source statuses `NOT_FOUND` and + `UNRESOLVABLE` as target-verification failures. Unknown future statuses retain + conservative generic behavior rather than being guessed. +2. Existing higher-information recovery remains preferred: a backend-provided site + suggestion or indexed alternative is rendered before generic target verification. +3. Text advice is client-owned and typed. Backend `note` strings remain structured + JSON data and are not copied into terminal/MCP text. +4. Package target guidance documents canonical inputs; it does not add aliases, fuzzy + resolution, new flags, or automatic repository fallback. +5. Package targets inspect the indexed package artifact or manifest root. Repository + targets are the supported escape hatch for full monorepos and sibling packages. +6. `text-v1` evolves in place. The root CLI and public `@githits/mcp` package + both receive patch-level change fragments; feature PRs do not bump package + versions or edit released changelogs. +7. Phase 2 starts only after private backend #2133 defines and deploys typed failure fields and their + retryability semantics. Field names and selections are intentionally not guessed. +8. Phase 1B uses formatter-authored ASCII punctuation, preserving the existing compact + text contract. Examples use ` | ` within summary rows and ` - ` between a target + and its compact healthy source list. +9. Product decisions for Phase 1B are closed: hits remain a separate ranked evidence + list; every requested target has at most one state row/block; target-specific + `Fix:`/`Try:` guidance stays directly below that target; only session-wide actions + use a final `Next:` line; and successful JSON envelopes remain lossless and + unchanged. + +## Architecture + +The shared presentation model remains the single behavior boundary: + +```text +backend search/searchStatus payload + -> unified JSON payload (lossless structured state) + -> shared presentation projection + - one semantic group per target + - source/readiness/trust/error facts attached to that group + - target-local recovery attached to that group + - query/session-wide continuation retained globally + -> CLI-native or MCP-native text renderer +``` + +The presentation layer owns semantic classification; renderers own surface wording. +This avoids duplicating status policy in CLI and MCP commands. Target syntax remains +owned by the existing parsers and service contract; public descriptions document that +contract without introducing a second normalization layer. + +Phase 1B corrects the current ownership split rather than merging strings only in the +renderer. `UnifiedSearchTargetGroup` becomes the sole renderable owner of target-scoped +identity, sources, exact terminal reason, freshness/coverage, constraints, alternatives, +site suggestions, and `Fix:`/`Try:` recovery. `UnifiedSearchPresentation` retains only +global lifecycle, availability, progress counts, query warnings, hits-related context, +and a session-wide continuation action. Projection may use local intermediate arrays, +but the returned semantic model must not expose duplicate render authorities for the +same target facts. + +Phase 1 changes only pure request-independent projection and text functions plus +descriptors/docs. It needs no service mock, dependency injection, container change, or +additional API field. Existing payload fixtures test it deterministically at the +presentation, CLI, MCP tool, and parity layers. + +## Cross-cutting constraints + +- **Security:** target labels and backend notes are untrusted text. The new action must + not interpolate additional backend prose or synthesize an executable command. The + broader search-metadata sanitation gap remains owned by + `docs/plans/terminal-text-sanitization.md` rather than being duplicated here. +- **Performance:** Phase 1 adds pure projection over the existing bounded source-status + list and no network fields or requests. It is not an optimization and needs no new + benchmark. +- **Compatibility:** structured JSON remains stable in Phase 1. `text-v1` wording + changes intentionally in place, with CLI/MCP parity maintained. +- **Token efficiency:** Phase 1B removes the standalone session row, repeated + `searchRef`, global target recovery, and separate target-warning block. Exact-output + tests assert one target identity and at most one target-local recovery instruction per + requested target. This is a representation change, not a runtime performance + optimization, so no execution benchmark is required. +- **Migration and rollback:** there is no stored data, schema migration, or rollout + flag. Reverting the client patch restores prior text behavior without backend state + changes. +- **Operations:** deterministic fixtures are the acceptance gate. Authenticated smoke + validates the deployed service but transient timeouts and indexing races are recorded + as external evidence, not hidden with retries. +- **Documentation:** stable MCP instructions and the public skill copy remain exactly + aligned; implementation docs own lasting behavior after this plan is deleted. + +## Phase map + +1. **Phase 1 (COMPLETE):** CLI and MCP text give deterministic terminal-target recovery, + preserve symbol/warning provenance, and document canonical Swift/Zig and package + scope. +2. **Phase 1B (COMPLETE):** CLI and MCP text expose one token-efficient state list + that keeps every transient, terminal, trust, warning, and recovery fact with its + target. Runtime commit `9b3523e` and this documentation/release closure record + the implemented contract; final-head validation is recorded, with the fresh Opus + review loop complete and all accepted findings closed. +3. **Phase 2 (BLOCKED):** CLI and MCP expose an actionable typed cause for terminal + `FAILED` sessions after private backend #2133 supplies the contract. + +## Phase 1: deterministic recovery and canonical guidance + +**Status:** COMPLETE + +Runtime behavior and focused tests are implemented in commit `56f6003`. Guidance, +durable documentation, release metadata, final repository validation, and internal +and external review are complete. Runtime/preflight fixes are recorded in `c88194b` +and `80f93a2`; privacy/wording review closure is recorded in `b6c0581`. + +**Expected outcome:** An invalid or unresolvable target cannot send a text caller into +an unchanged retry loop; symbol readiness and ignored-filter warnings identify the +actual lane; every relevant public search-addressing surface teaches the verified +Swift/Zig forms and package-scope boundary. + +**Assumptions:** The exact source states `NOT_FOUND` and `UNRESOLVABLE` are terminal; +the existing canonical target label is sufficient to distinguish package, GitHub +repository, standalone site, and unknown display families. + +**Unknowns or product decisions:** none. + +**Dependencies:** no backend change. Existing shared unified-search presentation and +text contracts remain the implementation boundary. + +### 1. Represent terminal target recovery explicitly + +Implemented in runtime commit `56f6003`: the shared action model has one typed +target-verification action. It is derived when a completed, empty search has any +source-status entry carrying an exact `NOT_FOUND` or `UNRESOLVABLE` state and no more +specific site suggestion or indexed alternative is available. +Evaluate exact terminal states carried by `indexingStatus` or `codeIndexState` after +site suggestions and indexed alternatives but before the generic +`hasIndexingTrustSignal` path. This precedence is required because a terminal +`UNRESOLVABLE` source may also carry target-resolution freshness `indexing`. + +Carry the deduplicated display families represented by all affected source entries: +package, GitHub repository, standalone site, or unknown. Derive those families from the +already-canonical target labels; do not rewrite the request or interpolate a new copy of +backend target text into the action. + +Render concise, surface-neutral guidance: + +- package: verify the registry package coordinate and version; if the coordinate is + correct but repository-wide evidence is needed, use its public GitHub repository; +- repository: verify the public GitHub repository and ref; +- site: verify the standalone site host/path; and +- unknown: verify or replace the unavailable target. + +Render one deduplicated positive guidance line per affected family. Do not emit +`rerun search later`, fabricate a `searchRef`, or turn terminal state into polling. +Preserve current polling for active sessions and current new-search handling for +terminal `DEFERRED`, `TIMEOUT`, `FAILED`, and unknown session statuses until Phase 2 +supplies better evidence. + +### 2. Preserve symbol and warning provenance + +Implemented in runtime commit `56f6003`: `symbols` is part of +`UnifiedSearchSourceKind` and backend `SYMBOL` source status maps to it. Source +ordering, compact labels, readiness summaries, empty-result summaries, and +action-related text keep code and symbol lanes distinct. A symbol-only completed search must +say `Searched: symbols`; code and symbol lanes must remain separately visible when +both are present. + +Change both constraint trust facts and projected ignored-filter warnings to carry +separate normalized backend lane and target fields, rather than storing a target in +the misleading `source` field. Normalize known `AUTO`, `CODE`, `DOCS`, and `SYMBOL` +values to lowercase. Preserve an unknown non-empty source as lowercase pass-through +instead of dropping the warning. Keep presentation source kinds such as `site_docs` +separate from the raw query lane. Render target-scoped source warnings as, for example, +`Ignored filter (docs on npm:express): fileIntent`. Query-wide warnings keep their +current query attribution. JSON warning and source-status shapes remain unchanged. + +### 3. Correct canonical target guidance + +The implementation updates all verified user-facing target descriptions listed above +with two compact rules: + +- Swift package names use `github.com//` after `swift:`; Zig package + names use `gh//` after `zig:`. +- Package targets are artifact/manifest-root scoped; use a public GitHub repository + target for the full repository or sibling packages. + +Keep the CLI search help compact and put the longer explanation in its description and +implementation docs. Update the shared code-navigation schema because it exposes the +same package target contract to `code_files`, `code_read`, and `code_grep`; do not +change those tools' first-80-character selection descriptions. + +`PACKAGE_TOOLS_PREAMBLE` and the exact corresponding paragraph in +`skills/githits-mcp/SKILL.md` were updated together. Generated plugin assets were +not edited directly; generation was run from canonical inputs and its empty diff +was inspected. + +### 4. Tests + +Implemented focused cases in the shared presentation/text tests and the existing +CLI/MCP search tests. They cover: + +- `NOT_FOUND` and `UNRESOLVABLE` package, repository, and site sources produce the + verification action and never contain `rerun search later`; +- multi-target and mixed-family terminal sources produce one deduplicated action + line per represented family without dropping the package-scope hint; +- a live-shaped `UNRESOLVABLE` fixture with target-resolution freshness `indexing` + still selects target verification after alternatives and before generic indexing; +- site suggestions and indexed alternatives remain higher-priority actions; +- active indexing still produces a polling action and `searchRef`; +- terminal session `FAILED` remains non-polling pending Phase 2; +- symbol-only, code-only, and mixed source readiness render distinct labels; +- a docs-lane ignored filter identifies `docs on ` and does not imply the code + lane ignored it; +- constraint trust facts and warning facts agree on the raw lane/target pair, including + symbol, hosted-doc, `AUTO`, and unknown-source cases; +- JSON payload regressions prove source status, warnings, hits, and error envelopes are + unchanged; and +- CLI/MCP parity covers the new recovery action and provenance wording. + +Descriptor/help contract assertions cover the Swift/Zig examples and package-scope +wording while preserving first-80 tool-description contracts. The MCP quick-start +parity test (`src/skills-packaging.test.ts`) and existing CLI registration/help tests +were updated instead of adding snapshots. The current +`MISSING`/`UNRESOLVABLE`/`FUTURE_STATE` table test was split: only `UNRESOLVABLE` +changes; `MISSING` and unknown future state keep conservative `new_search` +behavior. + +### 5. Documentation and release record + +Implemented in the owned documentation and guidance files: `cli-commands.md` and +`tools.md` now describe typed terminal recovery, the distinct symbol lane, warning +attribution, canonical Swift/Zig coordinates, and package/repository scope. Durable +target-failure guidance uses positive verification rather than retry-later wording. +The CLI target documentation's registry-prefix list mirrors canonical +`PKGSEER_REGISTRY_ARGS`, including Swift, RubyGems, and Go. + +Added one independent `changes/search-client-recovery.fixed.md` fragment with: + +```markdown +--- +"githits": patch +"@githits/mcp": patch +--- + +- **Search recovery and target guidance** - Correct terminal target recovery, + source provenance, and canonical package addressing. +``` + +The fragment describes corrected terminal recovery and target guidance in one +user-visible bullet. `CHANGELOG.md` and package versions remain untouched. + +### 6. Verification + +Final Phase 1 validation is complete. The full deterministic suite passes with 3522 +tests, along with `bun run typecheck`, `bun run format:check`, and `bun run lint`. +Build and package validation pass with `bun run build`, +`(cd packages/mcp && bun run build)`, `bun run validate:packages`, and +`bun run validate:packages:mcp-publish`. Plugin generation and validation pass with +`bun run plugins:generate` and `bun run plugins:check`. + +All four product smoke modes pass: `bun run smoke:cli`, `bun run smoke:mcp`, +`bun run smoke:cli:built`, and `bun run smoke:mcp:built`. The first live +`get_example` smoke attempt encountered a transient timeout; the rerun passed. Live +evidence covers canonical `swift:github.com/vapor/vapor` and `zig:gh/zigzap/zap` +targets, invalid short-form recovery, symbol-only labelling, and lane-aware warnings. +Six targeted agent evaluations passed across the MCP descriptor/full and skills +surfaces. Internal and external reviews are clean after runtime/preflight fixes +`c88194b` and `80f93a2`, followed by privacy/wording review closure `b6c0581`. + +### Phase 1 acceptance criteria + +- CLI and MCP `text-v1` outputs for terminal `NOT_FOUND` and `UNRESOLVABLE` sources + contain target-verification guidance, contain no unchanged retry-later advice, and + expose no `searchRef`. +- Active progress still polls; site suggestions and indexed alternatives remain the + preferred recovery when supplied. +- Symbol-only output says `symbols`, mixed code/symbol readiness keeps both lanes, and + source warning text identifies both lane and target. +- Successful JSON payload schemas, values, and error envelopes are unchanged for the + same service fixture. +- CLI help, MCP argument schemas, stable MCP instructions, and public skill guidance + include the verified Swift/Zig forms and artifact/manifest-root scope rule. +- Focused tests, the 3522-test full repository suite, type/lint/format checks, builds, + package validation, plugin generation/check, source and built smoke suites, and six + targeted agent evaluations complete with results recorded. +- One dual-package patch fragment exists; versions and released changelogs are + untouched. + +## Phase 1B: unified target-state text UX + +**Status:** COMPLETE + +**Expected outcome:** CLI human output and MCP `text-v1` present one compact semantic +list of requested targets. Each target's searched, indexing, terminal, stale or +provisional, alternative, site-suggestion, constraint, and recovery facts are readable +together. Progress is summarized in the outcome headline, target recovery is inline, +and the only final `Next:` line is a session/query-wide continuation. Ordinary healthy +results collapse to one `Sources:` row. Ranked hits remain separate. + +**Assumptions:** The current backend payload is sufficient. Exact source states +`NOT_FOUND` and `UNRESOLVABLE`, existing target-resolution provenance, alternatives, +site suggestions, contributor readiness, trust limits, and query warnings are the +complete inputs for this client projection. `text-v1` can evolve in place; JSON is the +stable programmatic contract. + +**Unknowns or product decisions:** none. The user selected one target-state list, +inline target recovery, a single global continuation, compact healthy collapse, and +token efficiency as a first-class acceptance constraint on 2026-08-29. + +**Dependencies:** Phase 1 projection and guidance are present on this branch. No backend +change, new API field, schema change, dependency, feature flag, migration, or new +infrastructure is required. Phase 2 remains independently blocked. + +### Exact text contract + +Formatter-authored punctuation remains ASCII. Labels are lower-case inside compact +state rows so repeated headings do not dominate agent context. Wording may wrap at the +existing width boundary, but ordering and semantic grouping are deterministic. + +The target-row vocabulary is fixed before implementation: + +| Token | Meaning | Structured source | +| --- | --- | --- | +| `searched: ` | These lanes ran, including a searched empty lane. | source/contributor state `searched` | +| `indexing: ` | These lanes are transiently waiting. | source state `waiting`; target freshness `indexing`/`pending` | +| `available: ` | Searchable but unsearched contributors or informational site suggestions. | contributor `READY`; bounded site suggestions when no `Try:` is emitted | +| `indexed: ` | Bounded already-indexed versions or repository refs that are informational while another action owns continuation. | target alternatives when no `Try:` is emitted | +| `using: while indexes` | An older served identity is supplying evidence during refresh. | requested/fresh/served divergence plus stale/indexing trust | +| ` not found: ` | Exact terminal `NOT_FOUND`. | source `indexingStatus`/`codeIndexState` | +| ` unresolved: ` | Exact terminal `UNRESOLVABLE` without an explicit version/ref component. | source terminal state plus parsed requested identity | +| `version unavailable: ` / `repository ref unresolved: ` | Exact `UNRESOLVABLE` with an explicit parsed package version/repository ref. | source terminal state plus existing target parser | +| `unavailable: ` | Conservative non-terminal/unknown unavailable state. | source state `unavailable` without an exact terminal reason | +| `ignored/incompatible (): ` | Target-scoped constraint warning. | deduplicated constraint trust/warning fact | +| `ready` / `pending` / `indexing` / `provisional` / `older snapshot` | Lane-free target state when progress/target freshness exists without source entries. | progress target freshness | + +Provisional and coverage facts qualify their source rather than adding another section: +`searched: code (provisional)` and `searched: docs (120 pages; partial)`. +`capped` uses the same coverage qualifier. Site identities remain concrete in detailed +rows (`n8n.io docs`); repository contributors remain `repository docs`. Detailed lane +order is `code`, `symbols`, `repository docs`, concrete ` docs`, then plain `docs`. +Segment order is `using`, `searched`, `indexing`, terminal/unavailable, `available`, +`indexed`, constraints. Empty +segments are omitted and duplicate lanes/facts are removed. When no source entry exists, +render the lane-free freshness token instead of inventing lanes from global +`requestedSources`; aggregate headline readiness does not replace per-target state. + +Ordinary completed, current results collapse all healthy groups to one line: + +```text +10 results | 6 repo code hits, 4 docs pages | next_offset=10 +Sources: npm:express@5.2.1 - code, site:expressjs.com, github:expressjs/express#dbac741a +``` + +Multiple healthy targets use one semicolon-delimited `Sources:` row, with each target +written once and its searched sources following it. Code and symbols remain compact lane +names. Documentation contributors retain canonical `site:` locators and +canonical `github:/#` locators. A source identical to its standalone +target is written once; a sole pinned repository source replaces its less-specific +ref-less repository target. An already-pinned repository target remains beside its resolved +commit. Compact repository provenance requires both the repository URL and commit; other +documentation without concrete provenance stays in detailed target-state form. Ranked hit +locators and JSON retain their existing provenance. + +Mixed progress and terminal state use the same target list: + +```text +1 partial result | 1 repo code hit | indexing | 1/3 ready + +- npm:express@5.2.1 | searched: code, docs +- pypi:fastapi | indexing: symbols +- swift:vapor | package not found: symbols + Fix: verify registry coordinate/version; use its public GitHub repo for repo-wide search. + +[1] ... + +Next: githits search-status abc123 --wait 20 +``` + +The express row in this example intentionally represents a plain `DOCS` source with no +contributors. A detailed row backed by documentation contributors uses `repository docs` +and concrete ` docs` identities instead. + +The MCP form differs only in the surface-native final command. The `searchRef` appears +once, in that executable continuation; there is no separate `Search ` session row. +The headline carries lower-case lifecycle and aggregate readiness when progress exists. +Active empty/no-snapshot forms are `No results yet | indexing | 0/1 ready` and +`No result snapshot yet | indexing | 0/1 ready`. Active result headlines preserve the +existing `partial` versus `interim` truth. Completed headlines omit lifecycle/readiness. +Terminal session headlines retain the terminal lifecycle without presenting a stopped +reference as actionable. For example, a failed progress response with no snapshot and +0/1 ready renders `No result snapshot | failed | 0/1 ready`. + +The exact headline grammar is: + +```text + [| ] [| ] [| / ready] [| next_offset=] +``` + +Known lifecycle words are `preparing`, `indexing`, `searching`, `deferred`, `timeout`, +and `failed`; an unknown future status renders `status unknown` rather than raw backend +enum text. Progress readiness is included for active, terminal, and unknown progress +responses. Active results retain `partial` versus `interim`, their type breakdown, and +pagination. Completed responses omit lifecycle/readiness. The old single-target +`from ` suffix is removed because the target list owns identity. Terminal and +unknown progress without a snapshot use `No result snapshot | | / +ready`; with an empty snapshot they use `No results | ...`. + +Completed target failures stay local and preserve the exact reason: + +```text +No results + +- npm:missing | package not found: code + Fix: verify registry coordinate/version; use its public GitHub repo for repo-wide search. +- github:owner/repo#bad-ref | repository ref unresolved: code + Fix: verify public GitHub repository/ref. +``` + +`NOT_FOUND` maps to ` not found`. `UNRESOLVABLE` uses `version unavailable` +only when the requested package target has an explicit parsed version, and `repository +ref unresolved` only when the requested repository target has an explicit parsed ref; +otherwise it renders ` unresolved`. Reuse `parsePackageSpec()` and +`parseRepositoryTargetSpec()` (or their already-projected requested metadata) rather +than adding string heuristics; scoped npm names and repository refs containing `@` must +retain their existing parser semantics. Unknown non-terminal unavailable states remain +`unavailable` and are not invented into a typed error. Source lanes remain attached to +every state. Family wording is client-owned; opaque backend notes remain JSON-only. +If the same target has any searched or indexing lane, omit the family prefix and +recovery line: +render `searched: code; not found: symbols` (or `unresolved: `). The successful +or actively indexing lane proves the target identity works, so telling the caller to +repair its coordinate would be false. Family-specific error/recovery wording applies +only when the target has no searched or indexing lane. Completed-empty and terminal +site suggestions are the explicit exception: they remain replayable `Try:` recovery +even when the site lane was searched empty. + +Higher-information recovery remains preferred and local: + +```text +No results + +- npm:express@99 | version unavailable: code + Try: npm:express@5.2.1 (also indexed: 5.1.0 +2) +``` + +When `Try:` is emitted, replayable candidates live only on that line: the first candidate +is the command-ready target and remaining bounded candidates appear as +`(also indexed: ... +N)`. Site suggestions use the same rule and preserve backend order; +the boolean truncation signal renders `+more` because no exact omitted count exists. +Candidates stay in the target row while a session-wide poll/status action owns the next +step only for a target that has no independently actionable terminal recovery. An active +site suggestion without an exact terminal reason is informational and stays in +`available:`; any site suggestion becomes `Try:`-eligible under completed-empty or +terminal/unknown-session recovery rules. If a target warrants `Try:`, its candidates live only on +that line even when poll/status for the wider session remains global. This prevents the +same candidate appearing on adjacent lines. + +Replayable target composition is typed: + +- a package version becomes `:@` using the parsed requested + package identity without its old version; +- a repository ref becomes the canonical `github:/#` using the parsed + repository identity; +- a site suggestion is already a replayable `site:` target; and +- package `availableRefs`/`suggestedRefs` remain informational because no valid package + target syntax can apply them. Do not invent `#` or silently switch to a + repository target. + +If source-status-only input lacks an explicit requested identity, compose from the group +primary identity (`requested ?? fresh ?? served`) after stripping its old version/ref +with the existing parser. Do not compose a `Try:` target if no parseable identity exists. + +A target gets at most one recovery line: a replayable indexed alternative or site target +wins over generic verification. Stable family copy is: + +- package: `Fix: verify registry coordinate/version; use its public GitHub repo for repo-wide search.` +- repository: `Fix: verify public GitHub repository/ref.` +- site: `Fix: verify site host/path.` +- unknown: `Fix: verify or replace target.` + +Indexing alone never gets target-local retry prose; an active final `Next:` polls once. + +Target-scoped ignored/incompatible filter or query-feature warnings move into the same +target block and name the affected lane without repeating the target. Query-wide warning +strings remain in one global `Warnings:` block because they have no target owner. +Stale/provisional snapshot identity and coverage stay in the target row/block. Hits +remain a separately numbered ranked evidence list after target state. The global +`Warnings:` block, when present, appears after the target list and before hits. + +For example, duplicated constraint/trust and promoted-warning representations collapse +to one group fact and one row segment: + +```text +- npm:express@5.2.1 | searched: code, docs; ignored filter (docs): fileIntent +``` + +The hardest current fixture becomes the durable detailed-output source of truth: + +```text +No results yet | indexing | 0/1 ready + +- npm:n8n -> 2.36.7 | indexing: code, repository docs; available: n8n.io docs (1,480 pages; capped); indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master + +Next: githits search-status --wait 20 +``` + +The line may wrap with the existing hanging indentation at the configured width. The +package ref candidates remain informational. There is no `Try:` because the active poll +is the session-wide next step. + +When `using:` is present, omit the identity `->` resolution suffix. If the fresh identity +equals the row identity, render it only once: +`- npm:express@5.2.1 | using: 5.1.0; searched: code`. The searched lane ran against +the served snapshot. Include `while indexes` only when `` differs from +the row identity, for example +`- npm:express | using: 5.1.0 while 5.2.1 indexes; searched: code`. +A target-level provisional fact qualifies every searched lane for that target; a +contributor-level provisional fact qualifies only that contributor. A target-scoped +constraint without a resolvable target attaches to the sole target group when exactly +one exists; otherwise it remains a global warning with its lane attribution. + +### Presentation contract and data flow + +1. Extend the source/target projection to retain the exact terminal reason on the + affected source entry instead of collapsing it to `unavailable` only. Preserve + `NOT_FOUND` versus `UNRESOLVABLE`; do not classify `MISSING` or unknown future values. +2. Attach target-scoped constraint warnings to the matching `UnifiedSearchTargetGroup` + using the same alias matching as sources, trust facts, alternatives, and suggestions. + Keep only query-wide warnings at presentation scope. +3. Project one target-local recovery value per group after all facts are attached: + replayable indexed alternative, replayable site suggestion, family-specific verify, + or none. This replaces global `site_retry`, `indexed_alternative`, and + `verify_target` actions. The global action union retains only poll/status, + new-search, query-rewrite, or none. Any target-local `Fix:`/`Try:` suppresses global + `new_search` and `query_rewrite`; those actions are allowed only when no target has + actionable recovery. Poll/status continuation remains independent. +4. Return target groups as the single semantic render authority. Remove redundant + top-level target/source/alternative/site/trust collections from + `UnifiedSearchPresentation` where they are used only to re-derive target output; + local projection intermediates are allowed. This module is shared internally and is + not an exported JSON/API schema. +5. Derive compact healthy `Sources:` entries from target groups, not flattened source + identities. The switch is all-or-nothing: render the single compact row only when + every group is searched, has explicit current freshness or no non-current freshness + signal, and is free of terminal/trust/warning/candidate/recovery facts; otherwise + render every target as a bullet, including healthy peers. + Compact rows collapse repository/site/general documentation contributors to `docs` + and use `code`, `symbols`, `docs` ordering. Detailed rows preserve `repository docs` + and concrete ` docs` identities. Dropping the healthy row's repository commit + suffix is an explicit token-efficiency trade-off; JSON retains exact provenance. + Compact identity uses `served ?? fresh ?? requested`, so the one-line source names + the evidence actually searched. +6. Fold lifecycle and aggregate readiness into `formatPresentationOutcome()`. Delete + the standalone session rendering path. Keep one session/query-wide action after hits, + with `searchRef` only when that action consumes it. + +Recovery/action gating is deterministic: + +| Lifecycle/snapshot | Target facts | Target-local action | Global action | +| --- | --- | --- | --- | +| active, any snapshot | exact terminal reason on a target with no searched/indexing lane | `Try:` when replayable, otherwise `Fix:` | `poll` when `searchRef` exists; local recovery does not suppress it | +| active, any snapshot | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | `poll` when `searchRef` exists | +| active, any snapshot | indexing/non-terminal alternatives only | none; show candidates in row | `poll` when `searchRef` exists | +| completed with hits | exact terminal reason on a target with no searched/indexing lane | `Try:` when replayable, otherwise `Fix:` | none, except existing `status` for an evidence notice | +| completed with hits | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | none, except existing `status` for an evidence notice | +| completed with hits | non-terminal candidates/site suggestions | none; show candidates in row | none, except existing `status` for an evidence notice | +| completed empty | exact terminal reason on a target with no searched/indexing lane, or any site suggestion | `Try:` when replayable, otherwise `Fix:` | none | +| completed empty | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | query rewrite for the searched-empty evidence | +| completed empty | indexing plus replayable indexed version/repository ref | `Try:` | none | +| completed empty | searched empty with no target recovery | none | query rewrite | +| completed empty | other unavailable/stale/indexing trust without replayable recovery | none | new search | +| terminal/unknown session | any target-local recovery, including suggestion-only site recovery | `Try:`/`Fix:` | none | +| terminal/unknown session | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | new search | +| terminal/unknown session | no target-local recovery or exact lane reason | none | new search | + +Within completed-empty multi-target output, after target-local recovery has taken +precedence, any indexing or pending target forces the global `new search` action +before a bare searched-empty terminal reason on another target can select query +rewrite. For a single target, that bare searched-empty terminal reason still selects +query rewrite. + +Any target-local recovery suppresses global `new_search` and `query_rewrite`, but never +suppresses an active `poll` or completed evidence `status`. Bare terminal reasons are +not local recovery: terminal/unknown sessions therefore use `new_search`, while +completed-empty searched evidence retains its query rewrite. This permits a mixed +active response to tell the caller how to repair one terminal target while polling +remaining indexing targets exactly once. + +Ownership check: target state belongs to `UnifiedSearchTargetGroup`; lifecycle and +continuation belong to `UnifiedSearchPresentation`. The earlier friction came from the +same target concept being owned by both the group and the global action/warning +collections. This boundary correction is smaller and more maintainable than adding +renderer-only suppression rules. + +### Affected components + +- `packages/mcp/src/shared/unified-search-presentation.ts`: target/source semantic + types, exact terminal classification, target warning/recovery projection, and the + reduced global action/presentation contract. +- `packages/mcp/src/shared/unified-search-text.ts`: compact healthy sources, one-row + target grammar, inline `Fix:`/`Try:`, headline progress, query warnings, and global + continuation. +- `packages/mcp/src/shared/unified-search-presentation.test.ts`: pure projection and + precedence coverage. +- `packages/mcp/src/shared/unified-search-text.test.ts` and + `packages/mcp/src/shared/unified-search-status-text.test.ts`: exact representative + output plus structural invariants. +- Existing CLI/MCP tool, command, parity, and smoke tests whose structural assertions + cover search/search-status text. Update only assertions affected by the intentional + text-v1 change; JSON fixtures remain unchanged. +- `packages/mcp/src/smoke-test.ts` and `scripts/cli-smoke.ts`: replace the public MCP + smoke helper and CLI smoke assumptions that require the deleted `Search | ...` + row or capitalized detail labels. Keep structural assertions for one target list, one + continuation, and `searchRef` appearing exactly once on the `Next:` line without + depending on obsolete prose. +- `docs/implementation/tools.md` and `docs/implementation/cli-commands.md`: lasting + target-state ownership, grammar, examples, and continuation contract. +- `changes/search-client-recovery.fixed.md`: expand the existing unreleased dual-package + patch fragment; do not create a second fragment for the same search recovery effort. + +No MCP description, input schema, stable instruction, public Agent Skill, generated +plugin asset, service query, or response-envelope file should change. If implementation +evidence contradicts that boundary, stop and replan before editing it. + +### Edge cases and precedence + +- One target with several lanes produces one row; lanes of the same state are + deduplicated and ordered deterministically. +- Multiple requested targets that resolve or serve aliases remain distinct when their + requested identities differ. Existing alias matching must not merge two requests. +- Exact terminal reason outranks generic `unavailable` even when target freshness also + says `indexing`; this preserves the Phase 1 precedence fix. +- A searched empty lane stays `searched`, not `unavailable`. A contributor `READY` + remains available but not searched. `MISSING` and unknown future source states remain + conservative `unavailable`. +- A stale served identity is stated once. Provisional, partial/capped coverage, and + target-scoped constraints remain visible without a second warning section. +- A terminal target with no searched/indexing lane plus a concrete alternative/suggestion + uses `Try:` only. Without a replayable candidate it uses `Fix:`. No target emits both. + Completed-empty and terminal site suggestions remain `Try:`-eligible after a searched + empty site lane. +- An explicit package version or repository ref is required before `UNRESOLVABLE` can + be described as version/ref-specific. Unversioned packages and implicit-ref + repositories use family-level `unresolved` wording. +- Query rewrite remains a single global `Next:` because it changes the whole search. + Terminal session `DEFERRED`, `TIMEOUT`, `FAILED`, and unknown statuses keep the current + non-polling new-search policy only when no target-local recovery or exact bare lane + reason exists; Phase 2 still owns richer session-failure semantics. +- ANSI changes emphasis only. Removing color from CLI output must leave MCP-equivalent + hierarchy and wording except for the final surface-native command. + +### Verification strategy + +Implement test-first at the pure presentation/text layers. Exact-output tests are +required for the four representative contracts above, the n8n contract, and a +multi-target warning/trust case. Use a wide explicit test width for exact semantic +grammar and separate default-80/narrow-width assertions for hanging continuation lines, +so wrapping does not obscure state-contract failures. Structural tests must additionally +prove: + +- each requested target identity appears once in the target-state area; +- `NOT_FOUND` and `UNRESOLVABLE` remain distinguishable and retain their source lane; +- versioned/unversioned packages and explicit/implicit repository refs receive only the + specificity justified by their parsed requested identity; +- each target has zero or one inline `Fix:`/`Try:` line and no target-specific global + `Next:` line; +- a target with both searched and exact-terminal lanes renders the bare terminal reason + on that lane and no coordinate-level recovery; +- an active response has one global continuation and one `searchRef` occurrence; +- progress readiness appears in the headline and no `Search ` session row remains; +- ordinary healthy output has one `Sources:` row and no target bullets; +- omitted freshness remains compact when no non-current signal exists; +- progress-only targets render exact lane-free states with and without global + `requestedSources`, without inventing per-target lanes; +- target warnings are inline, query warnings remain global, and neither is duplicated; +- a `FAILED` response with `NOT_FOUND` renders its inline `Fix:` and no global rerun; +- a completed evidence `status` can coexist with one target-local `Try:` without + repeating its candidates; +- row candidates use `indexed:` consistently with `Try:` parentheticals, and package + refs never become replayable targets; +- stale/provisional/coverage and site-truncation facts remain visible; +- CLI/MCP no-color output differs only in surface-native continuation syntax; +- successful CLI `--json` and MCP `format: "json"` payloads and error envelopes are + byte-for-byte/structurally unchanged for shared fixtures. + +Run focused presentation/text/status tests after each slice, then affected CLI/MCP/parity +tests. Final deterministic validation is `bun test`, `bun run typecheck`, +`bun run format:check`, `bun run lint`, `bun run build`, MCP package build, +`bun run validate:packages`, `bun run validate:packages:mcp-publish`, all four required +source/built CLI/MCP smoke modes, and targeted `bun run agent:e2e` workloads that inspect +mixed target state and continuation behavior. Agent evaluation must inspect actual tool +calls and final output for scanability and futile-retry behavior; it is qualitative +evidence, not a deterministic gate. Do not expose credentials or add retry machinery to +make live checks pass. + +### Documentation and release record + +Implemented in this closure slice. `docs/implementation/tools.md` and +`docs/implementation/cli-commands.md` now describe the single target-state list, +headline lifecycle/readiness, compact healthy `Sources:` output, inline terminal +`Fix:`/`Try:` recovery, query-only global warnings, one global `Next:`, and the +separate ranked hit list. The representative n8n output matches the runtime +formatter. Both documents state that JSON remains the lossless stable boundary. + +The existing `changes/search-client-recovery.fixed.md` fragment remains the one +dual-package patch record and now names both inline recovery and unified target-state +text. Versions and `CHANGELOG.md` remain untouched. This plan stays active because +Phase 2 is blocked. + +### Phase 1B implementation and verification record + +Runtime commit `9b3523e` implements the increment against plan baseline `28772a3`. +The replacement worker verified 144 focused presentation/text/status tests and 218 +consumer, parity, CLI/MCP, and smoke-helper tests, plus `bun run typecheck` and +Biome over all 14 owned runtime/test paths. During the interrupted worker handoff, one +test-suite deletion occurred transiently and was restored before those checks passed; +the original worker session also lost authentication and was replaced. No JSON, +schema, backend, skill, instruction, generated asset, or dependency change was made. + +Coordinator-owned full repository/package/build checks, live smoke, and targeted +agent evaluation are recorded below. The fresh Opus review loop is complete, with all +accepted findings closed and no further round warranted under the reviewer/max-three- +round loop. + +Runtime closure commits completed the remaining deterministic corrections: + +- `6b4a2d2` corrected the presentation alias return type and reused the computed + terminal reason in source projection. +- `c268cba` normalized `latest` package replay targets, retained terminal recovery + beside healthy peer hits, and made target-local `Fix:`/`Try:` output actionable in + both smoke validators. +- `e4cb960` restored terminal `new_search` for bare lane reasons, removed phantom + post-hit separators, and aligned smoke recognition with bare readiness states and + terminal reason labels. +- `49a6f23` closed R2-1 and R2-2, removed the redundant post-hit separator, and made + the smoke readiness fixtures discriminate grouped state details. +- `2c0c9e2` removed the genuinely unreachable post-lifecycle terminal-recovery branch; + it had zero runtime impact. + +At the final head after `49a6f23`, `bun test` passed 3,564 tests with 11,377 +expectations and 0 failures across 185 files. Typecheck, format/lint, root and MCP +builds, both package validations, and all four smoke modes passed: CLI live (89 +steps), MCP live (46), built CLI (18), and built MCP (7). The MCP publish dry-run was +skipped only because version 0.11.2 was already published. Targeted Claude and Codex +agent evaluation passed 4/4 with high confidence and no futile `search_status` +polling; that qualitative workload ran before `c268cba`, `e4cb960`, and `49a6f23`, so +those final shapes are covered by deterministic final-head tests and smoke suites +rather than by a rerun of the qualitative workload. + +Focused dead-code closure evidence after `2c0c9e2`: the presentation/text recovery +suite passed 145 tests with 459 expectations across 2 files; `bun run typecheck`, the +owned-file Biome check, and `git diff --check` passed. This behavior-neutral closure +is separate from the unchanged final-head validation above. + +The compact provenance follow-up implements the user-requested physical source display: +healthy documentation sources now render canonical `site:` and pinned +`github:/#` locators instead of the generic `docs` lane. A direct +ref-less repository target collapses to its sole pinned source; a pinned target remains +beside the resolved commit. Contributor-less documentation remains detailed because no +healthy live response shape was found that could truthfully supply compact provenance. + +At `dcbb9f9`, the focused formatter/CLI suite passed 153 tests with 560 expectations; +`bun test` passed 3,570 tests with 11,390 expectations and 0 failures; build, typecheck, +lint, format check, and `git diff --check` passed. Source and built live searches produced +`Sources: npm:express@5.2.1 - site:expressjs.com, github:expressjs/express#dbac741a` +and preserved the explicit mapping +`Sources: github:axios/axios#v1.7.9 - github:axios/axios#b2cb45d5`. All four smoke modes +passed at `bc51e67`. After the two subsequent repository-provenance fixes, the final +CLI smoke attempt stopped before its search assertions because the unrelated +`get_example` request timed out after 240 seconds; no retry or weakened assertion was +added. Direct source and built search runs passed after the final fix. + +The targeted Codex descriptor-profile agent evaluation at +`.agent-eval/runs/2026-08-31T09-21-05-040Z` completed successfully with high confidence, +eight logical MCP calls, no CLI fallback, and no instruction issue. Search provided +useful discovery evidence and the agent correctly used normal `next_offset` pagination +without inventing a `search_status` action. One broad `docs_read` range was unrelated; +a targeted follow-up read resolved it. + +The final preflight sibling scan found one stale optional session-row and duplicate +`searchRef` description in `docs/implementation/mcp-cli-parity.md`; this closure +updates that parity section to the implemented single-list contract. No runtime or +test behavior changed. + +### Plan review record + +- Internal technical review accepted two findings: target-local recovery suppresses + futile global reruns/rewrites, and version/ref-specific `UNRESOLVABLE` wording requires + an explicit parsed component. The speculative possibility that both backend status + fields carry conflicting exact terminal values was rejected for lack of fixture or + contract evidence; JSON remains the lossless diagnostic surface. +- Its closure pass accepted three normal-shape clarifications: lane-free progress target + states, candidate placement when global status and target-local `Try:` coexist, and + compact eligibility when healthy lean payloads omit freshness. +- Fresh Fable UX review round 1 accepted nine specification gaps: complete state tokens + and n8n output, stable family recovery copy, non-repeating candidate placement, + replayable target composition, lifecycle/action gating, all-or-nothing compact mode, + headline grammar, inline warning deduplication, and public smoke-helper coverage. +- Fable's request to rename `search-client-recovery.fixed.md` to `.changed.md` was + rejected. The single unreleased fragment describes this PR's overall terminal-recovery + bug fix; the UX consolidation is the fix's final text representation, not a separate + release category. The bullet will name both effects. +- Fable round 2 accepted four remaining contract contradictions and eight precision + gaps: active result breakdown, active site-suggestion gating, mixed searched/terminal + lane recovery, detailed docs fixture shape, non-repeating stale identity, provisional + qualification, deterministic lane/warning/outcome order, consistent `indexed:` copy, + identity fallbacks, completed-hit candidate gating, compact served identity, and the + exactly-once smoke reference assertion. No product decision or scope expansion was + required. +- Fable round 3, the final external plan round, verified all prior closures and found two + exact-output corrections plus three precision items. They were closed without another + external round: `using:` now states a fresh identity only when it differs from the row; + completed-empty and terminal suggestion-only site recovery remains actionable; matrix + precedence distinguishes a bare exact lane reason from generic terminal rerun; the + terminal no-snapshot example includes readiness; and coordinate recovery requires no + searched or indexing lane. No unresolved major finding or product decision remains. +- Internal code review accepted three implementation findings: latest package replay + target normalization, target-local recovery beside healthy peer hits, and smoke + actionability for target-local recovery. `c268cba` closes all three. +- Fresh Opus round 1 accepted F1 (terminal bare-lane dead end), N1 (trailing post-hit + separator), and N4 (smoke recognition of emitted readiness and terminal states). + `e4cb960` closes those findings. The empty-target guard was rejected as unverified, + and the active-site `available:` relabel was rejected because it contradicts the + explicit Phase 1B product decision. These remain rejected as N2 and N3. +- Opus round 2 verified all round-1 closures. A permitted fresh-context subreview + surfaced R2-1 (completed-empty indexing-peer precedence) and R2-2 + (terminal/unknown bare-reason candidate gating); Opus independently reproduced and + adjudicated both as accepted. Commit `49a6f23` closes R2-1 and R2-2 plus the + redundant-separator and discriminating smoke-fixture fixes. The unreachable branch + is closed separately by `2c0c9e2`. +- Final Opus round 3 reran the round-2 reproductions and verified R2-1 and R2-2, + including their single-target, site-suggestion, and local-recovery carve-outs. It + found only the low-severity unreachable recovery branch and a plan-record mismatch; + the branch had zero runtime impact, was removed in `2c0c9e2`, and this plan now + records the correction. The fresh Opus review loop is complete with all accepted + findings closed; no further round was warranted under the reviewer/max-three-round + loop. +- The compact provenance follow-up used a separate fresh Opus review loop. Round 1 + required replayable GitHub locators, direct-site deduplication, and hanging indentation; + `bc51e67` closed them. Round 2 required collapsing a ref-less direct repository target + to its sole pinned physical source; `c423ff5` also closed an internal finding by + requiring both repository URL and commit for compact provenance. Round 3 found that + the same-family collapse also erased an explicitly pinned request identity. The + suggested target-only remedy was rejected because it would discard the physical commit + provenance required by the user; `dcbb9f9` instead preserves both sides of that mapping. + The three-round external cap was reached, and the final internal changed-delta review + found no remaining finding. + +### Phase 1B acceptance criteria + +- The exact representative healthy, mixed, terminal, and alternative outputs satisfy + the contract above in CLI syntax, with MCP differing only in the final command. +- All target-scoped transient, terminal, trust, warning, alternative, suggestion, and + recovery facts render under one target identity; there is no separate in-progress, + unavailable, target-warning, or target-recovery list. +- Only query/session-wide guidance renders as final `Next:`. Active continuation contains + the sole visible `searchRef`; stopped terminal references are not rendered. +- Exact terminal reasons and lanes remain human/agent readable without raw backend prose; + unknown states remain conservative. +- Healthy output collapses to one target-plus-sources `Sources:` row, retaining concrete + documentation provenance when available; ranked hits and structured JSON remain + unchanged. +- Presentation, renderer, CLI/MCP/parity, and smoke-helper validation pass with the + final-head evidence recorded above. Full repository and build validation pass. All + four live smoke modes passed before the final two repository-only provenance fixes; + final source/built search probes pass, while the final CLI suite was blocked before + search by the recorded unrelated `get_example` timeout. Targeted agent-eval validation + is complete. Both fresh Opus loops exhausted their permitted review flow with all + accepted findings closed; the provenance loop's last post-fix check is internal because + its third external round found the final defect. +- Durable implementation docs and the existing dual-package patch fragment reflect the + final behavior; package versions, released changelogs, descriptors, skills, generated + assets, and backend requests remain unchanged. + +## Phase 2: terminal backend failure details + +**Status:** BLOCKED + +**Expected outcome:** A terminal `FAILED` search session exposes a bounded, typed cause +and an action consistent with backend-declared retryability while preserving any +returned evidence. + +**Assumptions:** Private backend #2133 will expose stable machine-readable failure and +retryability data on every search progress surface that can terminate as `FAILED`. + +**Unknowns or product decisions:** exact field names, failure categories, message trust +contract, and retry semantics. Private backend #2133 and a deployed schema resolve these before +Phase 2 can become READY. + +**Dependencies:** private backend #2133 implemented, deployed, and documented; Phase 1 merged +and reoriented against current `origin/main`. + +### Entry gate + +Start only after private backend #2133 is implemented, deployed, and documents: + +- a stable machine-readable failure code or category; +- a bounded display-safe message or client-owned mapping input; +- retryability and whether a fresh search can help; and +- field availability on both initial search and `search_status` progress responses. + +Re-run discovery against the deployed GraphQL schema before writing the detailed +increment. Report any contradiction with this plan immediately. + +### Expected outcome + +Select the smallest failure fields needed by every actual consumer. Compare the query +against compact text, verbose/text-v1, JSON, MCP, CLI, and internal callers before +changing it. Use conditional selections if detailed fields are mode-specific and add +wire-query tests proving compact modes do not over-fetch. + +Project typed terminal failure data through the shared payload and presentation model. +Render an actionable client-owned cause and recovery for `FAILED`; poll only when the +backend explicitly marks the state retryable and supplies a valid continuation +reference. Preserve any returned evidence. Never render opaque backend prose directly +or add timer/retry machinery. + +Update durable docs, add a separate dual-package patch fragment, and run the same +build, package, smoke, and agent-eval verification appropriate to changed MCP behavior. + +### Phase 2 acceptance criteria + +- Every deployed typed failure category has an explicit client projection and tested + recovery action; unknown categories fail conservatively without polling. +- CLI, MCP text, and JSON preserve terminal evidence and expose consistent failure + semantics without rendering opaque backend prose. +- GraphQL wire tests prove compact consumers fetch no unneeded failure detail and every + consumer that renders the cause selects the required fields. +- No timer, retry loop, or caller-ordering constraint is introduced. +- Durable docs, a separate dual-package patch fragment, full package validation, live + smoke, and relevant agent-eval evidence are complete. + +## Non-goals + +- Client-side workarounds for private backend #2128–#2132 or private backend #2123. +- Swift/Zig aliases, fuzzy resolution, automatic repository discovery, or registry + identity inference. +- Changing successful JSON envelopes in Phase 1. +- New flags, caches, queues, locks, feature flags, retry timers, or polling loops. +- Treating `crates:serde` as repository-wide; `serde_core` remains outside that + package target by design. +- Filing or coding around the single transient GraphQL timeout without reproducible + evidence. + +## Phase boundary and completion + +Phase 1 and Phase 1B are complete in the recorded commits above and stay within the +existing client-owned text projection. Final-head validation and the live-smoke limitation +are recorded, and both fresh Opus loops completed their permitted rounds with all accepted +findings closed before the updated draft PR is pushed. Use a +fresh `origin/main` comparison before beginning Phase 2, and do not mix speculative +Phase 2 fields into the client UX increment. + +This plan remains active while private backend #2133 blocks Phase 2. After both phases are implemented, +transfer all lasting contracts to `docs/implementation/`, verify no unresolved work +remains, and delete this plan. If the backend contract makes Phase 2 unnecessary or +materially different, revise the plan with the verified contradiction rather than +leaving stale instructions. diff --git a/packages/mcp/src/mcp/instructions.test.ts b/packages/mcp/src/mcp/instructions.test.ts index 8f30e7d7..19eea867 100644 --- a/packages/mcp/src/mcp/instructions.test.ts +++ b/packages/mcp/src/mcp/instructions.test.ts @@ -20,6 +20,16 @@ function buildLocal( } describe("buildLocalMcpQuickStart", () => { + it("documents canonical target guidance for package and repository scope", () => { + const quickStart = buildMcpQuickStart(); + + expect(quickStart).toContain("swift:github.com//"); + expect(quickStart).toContain("zig:gh//"); + expect(quickStart).toContain("artifact/manifest root"); + expect(quickStart).toContain("public GitHub repository"); + expect(quickStart).toContain("full repositories or sibling packages"); + }); + it("keeps deprecated instruction builders as exact compatibility aliases", () => { expect(buildMcpInstructions()).toBe(buildMcpQuickStart()); expect( diff --git a/packages/mcp/src/mcp/instructions.ts b/packages/mcp/src/mcp/instructions.ts index a846d162..d5e479f1 100644 --- a/packages/mcp/src/mcp/instructions.ts +++ b/packages/mcp/src/mcp/instructions.ts @@ -10,7 +10,7 @@ GitHits indexes public OSS/package evidence, not local workspaces, private repos When presenting \`get_example\` output, include source repository provenance/citations from GitHits' generated references/provenance section whenever present.`; -const PACKAGE_TOOLS_PREAMBLE = `Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use \`registry:name[@version]\`; repo targets use GitHub URLs. Prefer the default compact \`text-v1\` output; request JSON only when exact structured fields are necessary.`; +const PACKAGE_TOOLS_PREAMBLE = `Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use \`registry:name[@version]\` and inspect an indexed artifact/manifest root; Swift packages use \`swift:github.com//\` and Zig packages use \`zig:gh//\`. Use public GitHub repository targets for full repositories or sibling packages; repo targets use GitHub URLs. Prefer the default compact \`text-v1\` output; request JSON only when exact structured fields are necessary.`; const SEARCH_BULLET = "- `search` — discover relevant docs, code, tests, examples, and symbols in known packages/repos or exact `site:` documentation targets before reading exact files; retry advisory `suggestedSiteTargets` explicitly when returned."; diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 76609a25..dd038626 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -97,6 +97,32 @@ function source( }; } +function groupedSources( + presentation: ReturnType, +) { + return presentation.targetGroups.flatMap((group) => group.sources); +} + +function groupedTrustLimits( + presentation: ReturnType, +) { + return presentation.targetGroups.flatMap((group) => group.trustLimits); +} + +function groupedAlternatives( + presentation: ReturnType, +) { + return presentation.targetGroups.flatMap((group) => + group.alternatives ? [group.alternatives] : [], + ); +} + +function groupedSiteSuggestions( + presentation: ReturnType, +) { + return presentation.targetGroups.flatMap((group) => group.siteSuggestions); +} + describe("projectUnifiedSearchPresentation", () => { it.each(["PENDING", "INDEXING", "SEARCHING"] as const)( "keeps active lifecycle %s distinct", @@ -218,7 +244,7 @@ describe("projectUnifiedSearchPresentation", () => { hasSnapshot: true, resultCount: 0, }); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "code", entries: [ @@ -237,7 +263,7 @@ describe("projectUnifiedSearchPresentation", () => { }); }); - it("groups symbol source readiness with code", () => { + it("preserves symbol source readiness as symbols", () => { const presentation = projectUnifiedSearchPresentation( completed({ query: { raw: "router", sources: ["symbol"] }, @@ -252,9 +278,9 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { - kind: "code", + kind: "symbols", entries: [ { state: "searched", @@ -267,8 +293,25 @@ describe("projectUnifiedSearchPresentation", () => { ]); }); - it.each(["MISSING", "UNRESOLVABLE", "FUTURE_STATE"] as const)( - "treats source state %s as unavailable and suppresses pivots", + it("source and warning provenance keeps code and symbols as separate lanes", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ source: "CODE", codeIndexState: "CURRENT" }), + source({ source: "SYMBOL", codeIndexState: "CURRENT" }), + ], + }), + ); + + expect(groupedSources(presentation).map((group) => group.kind)).toEqual([ + "code", + "symbols", + ]); + }); + + it.each(["MISSING", "FUTURE_STATE"] as const)( + "terminal target recovery keeps source state %s unavailable with conservative pivots", (state) => { const presentation = projectUnifiedSearchPresentation( completed({ @@ -283,7 +326,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "code", entries: [ @@ -300,6 +343,230 @@ describe("projectUnifiedSearchPresentation", () => { }, ); + it("terminal target recovery selects exact unresolvable and missing states", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + source({ + targetLabel: "github:owner/repo#main", + indexingStatus: "UNRESOLVABLE", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + freshness: "indexing", + freshnessReason: "no_current_fallback", + requested: { repoUrl: "https://github.com/owner/repo" }, + availableVersions: [], + availableRefs: [], + }, + resultCount: 0, + }), + source({ + source: "docs", + targetLabel: "site:docs.example.com", + indexingStatus: "NOT_FOUND", + resultCount: 0, + }), + source({ + targetLabel: "opaque:target", + indexingStatus: "UNRESOLVABLE", + resultCount: 0, + }), + source({ + targetLabel: "npm:express@4.18.2", + indexingStatus: "UNRESOLVABLE", + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups.map((group) => group.recovery)).toEqual([ + { kind: "fix", family: "package" }, + { kind: "fix", family: "repository" }, + { kind: "fix", family: "unknown" }, + { kind: "fix", family: "site" }, + ]); + expect(presentation.action).not.toHaveProperty("searchRef"); + }); + + it("terminal target recovery keeps a registry target as package with repo resolution", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + targetResolution: { + requested: { repoUrl: "https://github.com/expressjs/express" }, + availableVersions: [], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "fix", + family: "package", + }); + }); + + it("terminal target recovery preserves site suggestion precedence", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + source: "docs", + targetLabel: "site:docs.example.com", + indexingStatus: "UNRESOLVABLE", + suggestedSiteTargets: ["site:docs.example.com/guide"], + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "site", + target: "site:docs.example.com/guide", + additionalTargets: [], + truncated: false, + }); + }); + + it("terminal target recovery preserves indexed alternative precedence", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + freshness: "indexing", + freshnessReason: "no_current_fallback", + availableVersions: [{ version: "4.17.0", ref: "v4.17.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "version", + target: "npm:express@4.17.0", + additionalTargets: [], + truncated: false, + }); + }); + + it("normalizes latest package display identities for recovery targets", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express latest", + requestedTarget: "npm:express latest", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + availableVersions: [{ version: "5.1.0", ref: "v5.1.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "version", + target: "npm:express@5.1.0", + additionalTargets: [], + truncated: false, + }); + }); + + it("keeps terminal recovery for a failed peer beside healthy hits", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "CURRENT", + resultCount: 1, + }), + source({ + targetLabel: "npm:missing@1.0.0", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + ], + }), + ); + + expect( + presentation.targetGroups.map((group) => ({ + target: group.identity.requested, + recovery: group.recovery, + })), + ).toEqual([ + { target: "npm:express@4.18.2", recovery: undefined }, + { + target: "npm:missing@1.0.0", + recovery: { kind: "fix", family: "package" }, + }, + ]); + expect(presentation.action).toEqual({ kind: "none" }); + }); + + it("terminal target recovery prefers indexed alternatives without freshness signals", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + targetResolution: { + availableVersions: [{ version: "4.17.0", ref: "v4.17.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "version", + target: "npm:express@4.17.0", + additionalTargets: [], + truncated: false, + }); + }); + it.each(["docs", "auto"] as const)( "uses neutral docs provenance for contributor-less %s sources", (sourceName) => { @@ -310,7 +577,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "docs", entries: [ @@ -345,14 +612,14 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.availability.kind).toBe(kind); expect(presentation.availability.hasSnapshot).toBe(false); - expect(presentation.sources).toEqual([]); + expect(groupedSources(presentation)).toEqual([]); expect(presentation.progress).toEqual({ targetsReady: 0, targetsTotal: 1, elapsedMs: 200, requestedSources: ["code"], }); - expect(presentation.targets).toEqual([]); + expect(presentation.targetGroups).toEqual([]); expect(presentation.action.kind).toBe("poll"); }); @@ -413,7 +680,7 @@ describe("projectUnifiedSearchPresentation", () => { hasSnapshot: false, resultCount: 0, }); - expect(presentation.sources).toEqual([]); + expect(groupedSources(presentation)).toEqual([]); expect(presentation.warnings).toEqual([]); }); @@ -467,7 +734,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "repository_docs", entries: [ @@ -502,7 +769,9 @@ describe("projectUnifiedSearchPresentation", () => { }, ]); expect( - presentation.trustLimits.filter((limit) => limit.kind === "source"), + groupedTrustLimits(presentation).filter( + (limit) => limit.kind === "source", + ), ).toEqual([ { kind: "source", @@ -548,7 +817,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "repository_docs", entries: [ @@ -575,7 +844,7 @@ describe("projectUnifiedSearchPresentation", () => { ], }, ]); - expect(presentation.trustLimits).toEqual( + expect(groupedTrustLimits(presentation)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "stale", @@ -614,15 +883,15 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([]); - expect(presentation.trustLimits).toEqual([]); - expect(presentation.targets).toEqual([ + expect(groupedSources(presentation)).toEqual([]); + expect(groupedTrustLimits(presentation)).toEqual([]); + expect(presentation.targetGroups.map((group) => group.identity)).toEqual([ { requested: "npm:n8n@2.36.7", freshness: "INDEXING", }, ]); - expect(presentation.alternatives).toEqual([ + expect(groupedAlternatives(presentation)).toEqual([ { target: "npm:n8n@2.36.7", versions: [ @@ -660,7 +929,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.alternatives).toEqual([ + expect(groupedAlternatives(presentation)).toEqual([ expect.objectContaining({ target: "npm:express latest", versions: [{ version: "4.18.2", ref: "v4.18.2" }], @@ -670,7 +939,7 @@ describe("projectUnifiedSearchPresentation", () => { refs: [{ ref: "main" }], }), ]); - expect(presentation.targets).toEqual([ + expect(presentation.targetGroups.map((group) => group.identity)).toEqual([ { requested: "npm:express latest" }, { requested: "github:expressjs/express#main" }, ]); @@ -700,15 +969,15 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.alternatives[0]?.versions).toEqual([ + expect(groupedAlternatives(presentation)[0]?.versions).toEqual([ { version: "4.0.0", ref: "v4.0.0" }, ]); - expect(presentation.alternatives[0]?.refs).toEqual([ + expect(groupedAlternatives(presentation)[0]?.refs).toEqual([ { ref: "master" }, { ref: sha }, { ref: secondSha }, ]); - expect(presentation.alternatives[0]?.refsRemaining).toBe(1); + expect(groupedAlternatives(presentation)[0]?.refsRemaining).toBe(1); }); it.each([ @@ -734,7 +1003,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.alternatives).toEqual([ + expect(groupedAlternatives(presentation)).toEqual([ expect.objectContaining({ target: identity }), ]); expect(presentation.targetGroups).toHaveLength(1); @@ -773,7 +1042,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.targets).toEqual([ + expect(presentation.targetGroups.map((group) => group.identity)).toEqual([ { requested: "npm:express latest", fresh: "npm:express@5.2.1", @@ -781,11 +1050,13 @@ describe("projectUnifiedSearchPresentation", () => { freshness: "STALE", }, ]); - expect(JSON.stringify(presentation.targets)).not.toContain("indexingRef"); - expect(JSON.stringify(presentation.targets)).not.toContain( + expect(JSON.stringify(presentation.targetGroups)).not.toContain( + "indexingRef", + ); + expect(JSON.stringify(presentation.targetGroups)).not.toContain( "OMITTED_VERSION", ); - expect(JSON.stringify(presentation.targets)).not.toContain( + expect(JSON.stringify(presentation.targetGroups)).not.toContain( "latest_version_indexing", ); }); @@ -962,7 +1233,7 @@ describe("projectUnifiedSearchPresentation", () => { expect( presentation.targetGroups.flatMap((group) => group.trustLimits), ).not.toContainEqual({ kind: "mutable_evidence" }); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "code", entries: [ @@ -1000,13 +1271,13 @@ describe("projectUnifiedSearchPresentation", () => { ], }, ]); - expect(presentation.alternatives[0]?.versions).toHaveLength(3); - expect(presentation.alternatives[0]?.versionsRemaining).toBe(1); - expect(presentation.alternatives[0]?.refs).toEqual([ + expect(groupedAlternatives(presentation)[0]?.versions).toHaveLength(3); + expect(groupedAlternatives(presentation)[0]?.versionsRemaining).toBe(1); + expect(groupedAlternatives(presentation)[0]?.refs).toEqual([ { ref: "HEAD" }, { ref: "master" }, ]); - expect(presentation.trustLimits).toEqual( + expect(groupedTrustLimits(presentation)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "source", state: "waiting" }), expect.objectContaining({ @@ -1014,7 +1285,6 @@ describe("projectUnifiedSearchPresentation", () => { state: "available_not_searched", }), expect.objectContaining({ kind: "coverage", state: "capped" }), - expect.objectContaining({ kind: "mutable_evidence" }), ]), ); expect(JSON.stringify(presentation)).not.toContain("indexingRef"); @@ -1112,10 +1382,11 @@ describe("projectUnifiedSearchPresentation", () => { servedTarget: "npm:express@5.1.0", }), ]); - expect(presentation.targets).toEqual([ + expect(presentation.targetGroups.map((group) => group.identity)).toEqual([ { requested: "npm:express latest", fresh: "npm:express@5.2.1", + served: "npm:express@5.1.0", freshness: "INDEXING", }, { @@ -1389,7 +1660,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.trustLimits).toEqual( + expect(groupedTrustLimits(presentation)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "stale", @@ -1425,7 +1696,7 @@ describe("projectUnifiedSearchPresentation", () => { const active = projectUnifiedSearchPresentation( incomplete({ partialResults: false, sourceStatus: [siteStatus] }), ); - expect(active.siteSuggestions).toEqual(expectedSuggestions); + expect(groupedSiteSuggestions(active)).toEqual(expectedSuggestions); expect(active.action).toEqual({ kind: "poll", searchRef: "search-ref-1", @@ -1434,9 +1705,16 @@ describe("projectUnifiedSearchPresentation", () => { const completedPresentation = projectUnifiedSearchPresentation( completed({ results: [], sourceStatus: [siteStatus] }), ); - expect(completedPresentation.siteSuggestions).toEqual(expectedSuggestions); - expect(completedPresentation.action).toEqual({ - kind: "site_retry", + expect(groupedSiteSuggestions(completedPresentation)).toEqual( + expectedSuggestions, + ); + expect(completedPresentation.action).toEqual({ kind: "none" }); + expect(completedPresentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "site", + target: "site:docs.example.com", + additionalTargets: ["site:api.example.com"], + truncated: true, }); for (const status of ["DEFERRED", "FUTURE_SESSION_STATE"] as const) { @@ -1452,8 +1730,13 @@ describe("projectUnifiedSearchPresentation", () => { }, }), ); - expect(terminal.action).toEqual({ - kind: "site_retry", + expect(terminal.action).toEqual({ kind: "none" }); + expect(terminal.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "site", + target: "site:docs.example.com", + additionalTargets: ["site:api.example.com"], + truncated: true, }); expect(terminal.action).not.toHaveProperty("searchRef"); } @@ -1483,18 +1766,28 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.warnings).toEqual([ { kind: "query", message: "unknown qualifier" }, + ]); + expect( + presentation.targetGroups[0]?.trustLimits.filter( + (limit) => limit.kind === "constraint", + ), + ).toEqual([ { - kind: "ignored_filter", - source: "site:expressjs.com", + kind: "constraint", + constraint: "ignored_filter", + source: "docs", + target: "site:expressjs.com", values: ["category"], }, { - kind: "incompatible_query_feature", - source: "site:expressjs.com", + kind: "constraint", + constraint: "incompatible_query_feature", + source: "docs", + target: "site:expressjs.com", values: ["exact_name"], }, ]); - expect(presentation.trustLimits).toEqual( + expect(groupedTrustLimits(presentation)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "coverage", state: "partial" }), ]), @@ -1505,6 +1798,75 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.action).toEqual({ kind: "new_search" }); }); + it("source and warning provenance keeps normalized lanes and targets", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + source: "DOCS", + targetLabel: "npm:express", + ignoredFilters: ["fileIntent"], + }), + source({ + source: "SYMBOL", + targetLabel: "npm:express", + incompatibleFilters: ["lang"], + }), + source({ + source: "AUTO", + targetLabel: "site:docs.example.com", + ignoredQueryFeatures: ["name"], + }), + source({ + source: "Future-Lane", + targetLabel: "opaque-target", + incompatibleQueryFeatures: ["kind"], + }), + source({ + source: "", + targetLabel: "npm:empty", + ignoredFilters: ["category"], + }), + ], + }), + ); + + expect(presentation.warnings).toEqual([]); + expect(groupedTrustLimits(presentation)).toEqual( + expect.arrayContaining([ + { + kind: "constraint", + constraint: "ignored_filter", + source: "docs", + target: "npm:express", + values: ["fileIntent"], + }, + { + kind: "constraint", + constraint: "incompatible_filter", + source: "symbol", + target: "npm:express", + values: ["lang"], + }, + { + kind: "constraint", + constraint: "ignored_query_feature", + source: "auto", + target: "site:docs.example.com", + values: ["name"], + }, + { + kind: "constraint", + constraint: "incompatible_query_feature", + source: "future-lane", + target: "opaque-target", + values: ["kind"], + }, + ]), + ); + }); + it("suppresses generic pivots for evidence limits and prefers indexed alternatives", () => { const presentation = projectUnifiedSearchPresentation( completed({ @@ -1523,11 +1885,13 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.action).toEqual({ - kind: "indexed_alternative", - target: "npm:express@4.18.2", + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", category: "version", - value: "4.17.0", + target: "npm:express@4.17.0", + additionalTargets: [], + truncated: false, }); }); @@ -1603,7 +1967,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.alternatives).toEqual([ + expect(groupedAlternatives(presentation)).toEqual([ { target: "npm:express latest", versions: versions.slice(0, 3), @@ -1615,4 +1979,171 @@ describe("projectUnifiedSearchPresentation", () => { }, ]); }); + + it("does not invent specificity for unversioned packages or implicit refs", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express", + codeIndexState: "UNRESOLVABLE", + }), + source({ + targetLabel: "github:owner/repo", + codeIndexState: "UNRESOLVABLE", + }), + ], + }), + ); + + expect( + presentation.targetGroups.map((group) => + group.sources.flatMap((sourceGroup) => + sourceGroup.entries.map((entry) => entry.terminalReason), + ), + ), + ).toEqual([ + [{ kind: "unresolvable", family: "package" }], + [{ kind: "unresolvable", family: "repository" }], + ]); + }); + + it("keeps a bare terminal lane reason beside indexing without local recovery", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + }, + sourceStatus: [ + source({ source: "code", codeIndexState: "INDEXING" }), + source({ source: "symbol", codeIndexState: "NOT_FOUND" }), + ], + }), + ); + + expect(presentation.targetGroups[0]?.recovery).toBeUndefined(); + expect(presentation.action).toEqual({ + kind: "poll", + searchRef: "search-ref-1", + }); + }); + + it.each(["TIMEOUT", "FAILED"] as const)( + "reruns a terminal response with a bare terminal lane reason: %s", + (status) => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + sourceStatus: [ + source({ + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + source: "symbol", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.targetGroups[0]?.recovery).toBeUndefined(); + expect(presentation.action).toEqual({ kind: "new_search" }); + }, + ); + + it("prefers a new search when another completed-empty target is indexing", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:one@1.0.0", + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + targetLabel: "npm:one@1.0.0", + source: "symbol", + codeIndexState: "UNRESOLVABLE", + resultCount: 0, + }), + source({ + targetLabel: "npm:two@2.0.0", + source: "code", + codeIndexState: "INDEXING", + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.targetGroups.map((group) => group.recovery)).toEqual([ + undefined, + undefined, + ]); + expect(presentation.action).toEqual({ kind: "new_search" }); + }); + + it("keeps terminal alternatives informational beside a bare lane reason", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + progress: { + status: "FAILED", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + sourceStatus: [ + source({ + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + source: "symbol", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + availableVersions: [{ version: "4.17.0", ref: "v4.17.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.targetGroups[0]?.recovery).toBeUndefined(); + expect(presentation.targetGroups[0]?.alternatives).toEqual( + expect.objectContaining({ + versions: [{ version: "4.17.0", ref: "v4.17.0" }], + }), + ); + expect(presentation.action).toEqual({ kind: "new_search" }); + }); + + it("uses query rewrite for completed-empty evidence without a reference", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ results: [], evidenceNotice: "mutable evidence" }), + ); + + expect(presentation.action).toEqual({ + kind: "query_rewrite", + rewrites: ["shorter_or_broader", "symbol", "code_grep"], + }); + }); }); diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 6f922049..0343721e 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1,3 +1,8 @@ +import { isKnownRegistry, parsePackageSpec } from "./package-spec.js"; +import { + formatRepositoryTarget, + parseRepositoryTargetSpec, +} from "./repository-target.js"; import type { LeanDocCoverage, UnifiedSearchCompletedPayload, @@ -40,6 +45,7 @@ export type UnifiedSearchLifecycle = export type UnifiedSearchSourceKind = | "code" + | "symbols" | "docs" | "repository_docs" | "site_docs"; @@ -49,6 +55,21 @@ export type UnifiedSearchSourceReadiness = | "available_not_searched" | "unavailable"; +export type UnifiedSearchTargetFamily = + | "package" + | "repository" + | "site" + | "unknown"; + +export type UnifiedSearchTerminalReasonKind = "not_found" | "unresolvable"; +export type UnifiedSearchTerminalSpecificity = "version" | "ref"; + +export interface UnifiedSearchTerminalReason { + kind: UnifiedSearchTerminalReasonKind; + family: UnifiedSearchTargetFamily; + specificity?: UnifiedSearchTerminalSpecificity; +} + export type UnifiedSearchFreshnessKind = | "current" | "stale" @@ -62,11 +83,14 @@ export interface UnifiedSearchSourceEntry { searchTarget: string; targetAliases?: string[]; requestedTarget?: string; + freshTarget?: string; + servedTarget?: string; resultCount?: number; repositoryUrl?: string; commitSha?: string; siteKey?: string; siteUrl?: string; + terminalReason?: UnifiedSearchTerminalReason; } type SourceIdentity = Pick< @@ -75,6 +99,8 @@ type SourceIdentity = Pick< | "searchTarget" | "targetAliases" | "requestedTarget" + | "freshTarget" + | "servedTarget" | "repositoryUrl" | "commitSha" | "siteKey" @@ -132,8 +158,19 @@ export interface UnifiedSearchTargetGroup { alternatives?: UnifiedSearchAlternativeFacts; siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; trustLimits: UnifiedSearchTrustLimit[]; + recovery?: UnifiedSearchTargetRecovery; } +export type UnifiedSearchTargetRecovery = + | { + kind: "try"; + category: "version" | "ref" | "site"; + target: string; + additionalTargets: string[]; + truncated: boolean; + } + | { kind: "fix"; family: UnifiedSearchTargetFamily }; + export type UnifiedSearchConstraintKind = | "ignored_filter" | "incompatible_filter" @@ -170,6 +207,7 @@ export type UnifiedSearchTrustLimit = kind: "constraint"; constraint: UnifiedSearchConstraintKind; source?: string; + target?: string; values: string[]; } | { kind: "mutable_evidence" }; @@ -179,6 +217,7 @@ export type UnifiedSearchWarning = | { kind: UnifiedSearchConstraintKind; source?: string; + target?: string; values: string[]; }; @@ -186,13 +225,6 @@ export type UnifiedSearchAction = | { kind: "poll"; searchRef: string } | { kind: "status"; searchRef: string } | { kind: "new_search" } - | { kind: "site_retry" } - | { - kind: "indexed_alternative"; - target?: string; - category: "version" | "ref"; - value: string; - } | { kind: "query_rewrite"; rewrites: UnifiedSearchRewriteKind[]; @@ -210,16 +242,10 @@ export interface UnifiedSearchPresentation { availability: UnifiedSearchAvailability; lifecycle: UnifiedSearchLifecycle; query?: UnifiedSearchQueryEcho; - searchRef?: string; progress?: UnifiedSearchProgressPresentation; - targets: UnifiedSearchTargetPresentation[]; targetGroups: UnifiedSearchTargetGroup[]; hasMore: boolean; - sources: UnifiedSearchSourceGroup[]; - siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; - trustLimits: UnifiedSearchTrustLimit[]; warnings: UnifiedSearchWarning[]; - alternatives: UnifiedSearchAlternativeFacts[]; action: UnifiedSearchAction; } @@ -256,7 +282,6 @@ export function projectUnifiedSearchPresentation( const trustLimits = projectTrustLimits(snapshot, sources, sourceStatus); const query = snapshot?.query ?? ("query" in payload ? payload.query : undefined); - const warnings = projectWarnings(query, sourceStatus); const alternatives = projectAlternatives(progress, sourceStatus); const searchRef = "searchRef" in payload ? payload.searchRef : undefined; const targets = projectTargets(progress); @@ -266,30 +291,26 @@ export function projectUnifiedSearchPresentation( alternatives, siteSuggestions, trustLimits, + lifecycle, + availability, + snapshot, }); + const warnings = projectWarnings(query, sourceStatus, targetGroups); return { availability, lifecycle, query, - searchRef, progress: projectProgress(progress), - targets, targetGroups, hasMore: snapshot?.hasMore ?? false, - sources, - siteSuggestions, - trustLimits, warnings, - alternatives, action: projectAction({ searchRef, snapshot, lifecycle, availability, - siteSuggestions, - trustLimits, - alternatives, + targetGroups, }), }; } @@ -400,10 +421,12 @@ function projectSources( } const kind = sourceKind(entry); + const terminalReason = sourceTerminalReason(entry); appendSourceEntry(groups, kind, { state: sourceState(entry), ...sourceIdentity(entry, kind), resultCount: entry.resultCount, + ...(terminalReason ? { terminalReason } : {}), }); } return groups; @@ -439,7 +462,8 @@ function sourceKind( entry: UnifiedSearchSourceStatusPayload, ): UnifiedSearchSourceKind { const source = entry.source.toLowerCase(); - if (source === "code" || source === "symbol") return "code"; + if (source === "code") return "code"; + if (source === "symbol") return "symbols"; if (isSiteTarget(entry.targetLabel, entry)) return "site_docs"; return entry.targetResolution?.served?.repoUrl ? "repository_docs" : "docs"; } @@ -493,7 +517,10 @@ function sourceIdentity( function sourceTargetAliases( entry: UnifiedSearchSourceStatusPayload, -): Pick { +): Pick< + UnifiedSearchSourceEntry, + "targetAliases" | "requestedTarget" | "freshTarget" | "servedTarget" +> { const aliases = uniqueAliases([ entry.targetLabel, entry.requestedTarget, @@ -505,6 +532,8 @@ function sourceTargetAliases( ...(entry.requestedTarget ? { requestedTarget: entry.requestedTarget } : {}), + ...(entry.freshTarget ? { freshTarget: entry.freshTarget } : {}), + ...(entry.servedTarget ? { servedTarget: entry.servedTarget } : {}), }; } @@ -529,6 +558,41 @@ function sourceState( : "unavailable"; } +function sourceTerminalReason( + entry: UnifiedSearchSourceStatusPayload, +): UnifiedSearchTerminalReason | undefined { + const kind = [entry.indexingStatus, entry.codeIndexState].find( + (state): state is UnifiedSearchTerminalReasonKind => + state === "NOT_FOUND" || state === "UNRESOLVABLE", + ); + if (!kind) return undefined; + const family = classifyTargetFamily(entry); + const requestedTarget = entry.requestedTarget ?? entry.targetLabel; + const specificity = terminalSpecificity(family, requestedTarget); + return { + kind: kind.toLowerCase() as UnifiedSearchTerminalReasonKind, + family, + ...(specificity ? { specificity } : {}), + }; +} + +function terminalSpecificity( + family: UnifiedSearchTargetFamily, + target: string, +): UnifiedSearchTerminalSpecificity | undefined { + try { + if (family === "package" && parsePackageSpec(target).version) { + return "version"; + } + if (family === "repository" && parseRepositoryTargetSpec(target).gitRef) { + return "ref"; + } + } catch { + return undefined; + } + return undefined; +} + function contributorState( state: "SEARCHED" | "READY" | "PENDING" | "UNAVAILABLE", ): UnifiedSearchSourceReadiness { @@ -688,7 +752,13 @@ function addConstraints( const target = entry.targetLabel; for (const [constraint, values] of sourceConstraints(entry)) { if (values?.length) - add({ kind: "constraint", constraint, source: target, values }); + add({ + kind: "constraint", + constraint, + source: normalizeSourceLane(entry.source), + target: target || undefined, + values, + }); } } @@ -706,20 +776,39 @@ function sourceConstraints( function projectWarnings( query: UnifiedSearchQueryEcho | undefined, sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, + targetGroups: UnifiedSearchTargetGroup[], ): UnifiedSearchWarning[] { const warnings: UnifiedSearchWarning[] = []; for (const message of query?.warnings ?? []) { warnings.push({ kind: "query", message }); } for (const entry of sourceStatus ?? []) { - const source = entry.targetLabel; + const source = normalizeSourceLane(entry.source); + const target = entry.targetLabel || undefined; + const aliases = uniqueAliases([ + entry.targetLabel, + entry.requestedTarget, + entry.freshTarget, + entry.servedTarget, + ]); + const hasTargetOwner = + findMatchingTargetGroup(targetGroups, aliases, entry.requestedTarget) !== + undefined || + (targetGroups.length === 1 && target === undefined); for (const [kind, values] of sourceConstraints(entry)) { - if (values?.length) warnings.push({ kind, source, values }); + if (values?.length && !hasTargetOwner) { + warnings.push({ kind, source, target, values }); + } } } return warnings; } +function normalizeSourceLane(source: string | undefined): string | undefined { + const normalized = source?.trim().toLowerCase(); + return normalized || undefined; +} + function projectAlternatives( progress: UnifiedSearchProgressPayload | undefined, sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, @@ -787,6 +876,9 @@ interface TargetGroupInput { alternatives: UnifiedSearchAlternativeFacts[]; siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; trustLimits: UnifiedSearchTrustLimit[]; + lifecycle: UnifiedSearchLifecycle; + availability: UnifiedSearchAvailability; + snapshot: SnapshotFacts | undefined; } function projectTargetGroups( @@ -871,6 +963,11 @@ function projectTargetGroups( entry.searchTarget, entry.requestedTarget, ); + if (entry.requestedTarget) { + group.identity.requested ??= entry.requestedTarget; + } + if (entry.freshTarget) group.identity.fresh ??= entry.freshTarget; + if (entry.servedTarget) group.identity.served ??= entry.servedTarget; const existingSource = group.sources.find( (candidate) => candidate.kind === sourceGroup.kind, ); @@ -890,7 +987,7 @@ function projectTargetGroups( findOrCreate(suggestion.target).siteSuggestions.push(suggestion); } for (const limit of input.trustLimits) { - if (limit.kind === "constraint" || limit.kind === "mutable_evidence") { + if (limit.kind === "mutable_evidence") { continue; } const target = "target" in limit ? limit.target : undefined; @@ -922,13 +1019,23 @@ function projectTargetGroups( } group.trustLimits.push(limit); } + for (const group of groups) { + const recovery = projectTargetRecovery( + group, + input.lifecycle, + input.availability, + input.snapshot, + ); + if (recovery) group.recovery = recovery; + } return groups.filter( (group) => targetIdentityValues(group.identity).length > 0 || group.sources.length > 0 || group.alternatives !== undefined || group.siteSuggestions.length > 0 || - group.trustLimits.length > 0, + group.trustLimits.length > 0 || + group.recovery !== undefined, ); } @@ -1117,9 +1224,7 @@ interface ActionInput { snapshot: SnapshotFacts | undefined; lifecycle: UnifiedSearchLifecycle; availability: UnifiedSearchAvailability; - siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; - trustLimits: UnifiedSearchTrustLimit[]; - alternatives: UnifiedSearchAlternativeFacts[]; + targetGroups: UnifiedSearchTargetGroup[]; } function projectAction(input: ActionInput): UnifiedSearchAction { @@ -1129,38 +1234,53 @@ function projectAction(input: ActionInput): UnifiedSearchAction { : { kind: "none" }; } if ( - input.lifecycle.kind === "terminal" || - input.lifecycle.kind === "unknown" + input.lifecycle.kind === "completed" && + input.snapshot?.evidenceNotice !== undefined && + input.searchRef + ) { + return { kind: "status", searchRef: input.searchRef }; + } + + const hasLocalRecovery = input.targetGroups.some( + (group) => group.recovery !== undefined, + ); + const hasBareTerminalReason = input.targetGroups.some( + hasBareTerminalReasonForGroup, + ); + if ( + (input.lifecycle.kind === "terminal" || + input.lifecycle.kind === "unknown") && + hasLocalRecovery ) { - return input.siteSuggestions.length > 0 - ? { kind: "site_retry" } - : { kind: "new_search" }; + return { kind: "none" }; } if ( - input.lifecycle.kind === "completed" && - input.snapshot?.evidenceNotice !== undefined + input.lifecycle.kind === "terminal" || + input.lifecycle.kind === "unknown" ) { - if (input.searchRef) return { kind: "status", searchRef: input.searchRef }; + return { kind: "new_search" }; } - if (!input.snapshot || input.availability.kind !== "empty") + if (!input.snapshot || input.availability.kind !== "empty") { return { kind: "none" }; - - const hasIndexing = hasIndexingTrustSignal(input.snapshot.sourceStatus); - if (hasIndexing) { - const alternative = firstAlternative(input.alternatives); - if (alternative) return alternative; - return { kind: "new_search" }; } - if (input.siteSuggestions.length > 0) { - return { kind: "site_retry" }; + + if (hasLocalRecovery) return { kind: "none" }; + const hasIndexing = input.targetGroups.some((group) => + groupHasIndexing(group), + ); + if (hasIndexing) return { kind: "new_search" }; + if (hasBareTerminalReason) { + return projectQueryRewrite(input.snapshot.query); } if ( - input.trustLimits.some( - (limit) => - limit.kind === "source" || - limit.kind === "coverage" || - limit.kind === "mutable_evidence" || - limit.kind === "stale", + input.targetGroups.some((group) => + group.trustLimits.some( + (limit) => + limit.kind === "source" || + limit.kind === "coverage" || + limit.kind === "mutable_evidence" || + limit.kind === "stale", + ), ) ) { return { kind: "new_search" }; @@ -1177,7 +1297,12 @@ function projectAction(input: ActionInput): UnifiedSearchAction { rewrites: ["site_shorter_or_broader"], }; } - const query = input.snapshot.query; + return projectQueryRewrite(input.snapshot.query); +} + +function projectQueryRewrite( + query: UnifiedSearchQueryEcho | undefined, +): Extract { const rewrites: UnifiedSearchRewriteKind[] = ["shorter_or_broader"]; if (hasRestrictiveFilters(query)) rewrites.push("remove_filters"); const symbolSource = query?.sources?.some( @@ -1188,50 +1313,235 @@ function projectAction(input: ActionInput): UnifiedSearchAction { return { kind: "query_rewrite", rewrites }; } -function firstAlternative( - alternatives: UnifiedSearchAlternativeFacts[], -): UnifiedSearchAction | undefined { - for (const alternative of alternatives) { - const version = alternative.versions[0]; - if (version) { - return { - kind: "indexed_alternative", - target: alternative.target, - category: "version", - value: version.version ?? version.ref, - }; - } - const ref = alternative.refs[0]; - if (ref) { - return { - kind: "indexed_alternative", - target: alternative.target, - category: "ref", - value: ref.ref, - }; +function projectTargetRecovery( + group: UnifiedSearchTargetGroup, + lifecycle: UnifiedSearchLifecycle, + availability: UnifiedSearchAvailability, + snapshot: SnapshotFacts | undefined, +): UnifiedSearchTargetRecovery | undefined { + const hasTerminalReason = groupHasTerminalReason(group); + const hasBareTerminalReason = hasBareTerminalReasonForGroup(group); + const alternative = projectAlternativeRecovery(group); + const site = projectSiteRecovery(group); + const candidate = site ?? alternative; + + if (lifecycle.kind === "active") { + return hasTerminalReason && !hasBareTerminalReason + ? (candidate ?? fixRecovery(group)) + : undefined; + } + if (lifecycle.kind === "terminal" || lifecycle.kind === "unknown") { + if (hasBareTerminalReason) return undefined; + if (candidate) return candidate; + return hasTerminalReason ? fixRecovery(group) : undefined; + } + if (lifecycle.kind === "completed" && hasTerminalReason) { + return hasBareTerminalReason + ? undefined + : (candidate ?? fixRecovery(group)); + } + if (availability.kind !== "empty" || !snapshot) return undefined; + + if (site) return site; + return groupHasIndexing(group) ? alternative : undefined; +} + +function projectAlternativeRecovery( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTargetRecovery | undefined { + const alternatives = group.alternatives; + if (!alternatives) return undefined; + const identity = primaryTargetIdentity(group); + if (!identity) return undefined; + const family = groupTerminalFamily(group) ?? familyForTarget(identity); + if (family === "package") { + const versions = alternatives.versions + .map((alternative) => composePackageTarget(identity, alternative.version)) + .filter((target): target is string => target !== undefined); + const target = versions[0]; + if (!target) return undefined; + return { + kind: "try", + category: "version", + target, + additionalTargets: versions.slice(1), + truncated: alternatives.versionsRemaining > 0, + }; + } + if (family === "repository") { + const refs = [...alternatives.refs, ...alternatives.suggestedRefs] + .map((alternative) => composeRepositoryTarget(identity, alternative.ref)) + .filter((target): target is string => target !== undefined); + const unique = [...new Set(refs)]; + const target = unique[0]; + if (!target) return undefined; + return { + kind: "try", + category: "ref", + target, + additionalTargets: unique.slice(1), + truncated: + alternatives.refsRemaining > 0 || + alternatives.suggestedRefsRemaining > 0, + }; + } + return undefined; +} + +function projectSiteRecovery( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTargetRecovery | undefined { + const suggestions = [ + ...new Set( + group.siteSuggestions.flatMap((suggestion) => suggestion.suggestions), + ), + ]; + const target = suggestions[0]; + if (!target) return undefined; + return { + kind: "try", + category: "site", + target, + additionalTargets: suggestions.slice(1), + truncated: group.siteSuggestions.some((suggestion) => suggestion.truncated), + }; +} + +function fixRecovery( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTargetRecovery { + return { + kind: "fix", + family: + groupTerminalFamily(group) ?? + familyForTarget(primaryTargetIdentity(group)), + }; +} + +function primaryTargetIdentity( + group: UnifiedSearchTargetGroup, +): string | undefined { + return ( + group.identity.requested ?? + group.identity.fresh ?? + group.identity.served ?? + group.alternatives?.target + ); +} + +function groupTerminalReason( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTerminalReason | undefined { + for (const source of group.sources) { + for (const entry of source.entries) { + if (entry.terminalReason) return entry.terminalReason; } } return undefined; } -function hasIndexingTrustSignal( - sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, +function groupTerminalFamily( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTargetFamily | undefined { + return groupTerminalReason(group)?.family; +} + +function groupHasTerminalReason(group: UnifiedSearchTargetGroup): boolean { + return groupTerminalReason(group) !== undefined; +} + +function groupHasIndexing(group: UnifiedSearchTargetGroup): boolean { + return ( + group.freshnessKind === "indexing" || + group.freshnessKind === "pending" || + group.sources.some((source) => + source.entries.some((entry) => entry.state === "waiting"), + ) + ); +} + +function hasBareTerminalReasonForGroup( + group: UnifiedSearchTargetGroup, ): boolean { - return Boolean( - sourceStatus?.some( - (entry) => - entry.indexingStatus === "INDEXING" || - entry.codeIndexState === "INDEXING" || - entry.codeIndexState === "PROVISIONAL" || - entry.targetResolution?.freshness === "indexing" || - entry.targetResolution?.freshness === "provisional" || - entry.contributors?.some( - (contributor) => contributor.freshness === "PROVISIONAL", - ), - ), + return ( + groupHasTerminalReason(group) && + group.sources.some((source) => + source.entries.some( + (entry) => entry.state === "searched" || entry.state === "waiting", + ), + ) ); } +function familyForTarget( + target: string | undefined, +): UnifiedSearchTargetFamily { + if (!target) return "unknown"; + if (isSiteTarget(target, { targetLabel: target, source: "docs" })) { + return "site"; + } + const packageSeparator = target.indexOf(":"); + if ( + packageSeparator > 0 && + isKnownRegistry(target.slice(0, packageSeparator)) + ) { + return "package"; + } + if (target.startsWith("github:")) return "repository"; + try { + parseRepositoryTargetSpec(target); + return "repository"; + } catch { + return "unknown"; + } +} + +function composePackageTarget( + identity: string, + version: string | undefined, +): string | undefined { + if (!version) return undefined; + try { + const parsed = parsePackageSpec(identity.trim().replace(/\s+latest$/, "")); + return `${parsed.registry}:${parsed.name}@${version}`; + } catch { + return undefined; + } +} + +function composeRepositoryTarget( + identity: string, + ref: string, +): string | undefined { + try { + const parsed = parseRepositoryTargetSpec(identity); + if (!parsed.repoUrl) return undefined; + return formatRepositoryTarget(parsed.repoUrl, ref); + } catch { + return undefined; + } +} + +function classifyTargetFamily( + entry: UnifiedSearchSourceStatusPayload, +): UnifiedSearchTargetFamily { + if (isSiteTarget(entry.targetLabel, entry)) return "site"; + const target = entry.targetLabel.trim().toLowerCase(); + const separator = target.indexOf(":"); + if (separator > 0 && isKnownRegistry(target.slice(0, separator))) { + return "package"; + } + if ( + target.startsWith("github:") || + entry.targetResolution?.requested?.repoUrl || + entry.targetResolution?.resolvedRequested?.repoUrl || + entry.targetResolution?.served?.repoUrl + ) { + return "repository"; + } + return "unknown"; +} + function hasRestrictiveFilters( query: UnifiedSearchQueryEcho | undefined, ): boolean { diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index 4a11e7d0..c2ddf89d 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -56,12 +56,11 @@ describe("renderUnifiedSearchStatusText", () => { const text = renderUnifiedSearchStatusText(payload); expect(firstLine(text)).toBe( - "Indexing continues - 1 interim result returned", + "1 interim result | 1 docs page | indexing | 0/1 ready", ); expect(text).toContain( "express/routing [docs page] npm:express - source URL unavailable - Routing", ); - expect(text).toContain("Search search-ref-status | 0/1 target ready"); expect(text).toContain( 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', ); @@ -76,7 +75,7 @@ describe("renderUnifiedSearchStatusText", () => { }), ); expect(firstLine(statusText)).toBe( - "Indexing continues - 1 partial result returned", + "1 partial result | 1 docs page | indexing | 0/1 ready", ); }); @@ -85,7 +84,7 @@ describe("renderUnifiedSearchStatusText", () => { result: result({ partialResults: true, results: [hit()] }), }); const text = renderUnifiedSearchStatusText(payload); - expect(firstLine(text)).toContain("1 partial result returned"); + expect(firstLine(text)).toContain("1 partial result"); expect(text).not.toContain("1 interim result"); }); @@ -101,11 +100,12 @@ describe("renderUnifiedSearchStatusText", () => { }, }), ); - expect(firstLine(text)).toBe("Preparing - no result snapshot yet"); + expect(firstLine(text)).toBe( + "No result snapshot yet | preparing | 0/1 ready", + ); expect(text).not.toContain("Indexing:"); expect(text).not.toContain("No hits"); expect(text).toContain("- npm:express"); - expect(text).toContain("Search search-ref-status | 0/1 target ready"); expect(text).toContain( 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', ); @@ -126,12 +126,61 @@ describe("renderUnifiedSearchStatusText", () => { }), }; const text = renderUnifiedSearchStatusText(payload); - expect(firstLine(text)).toContain("No results returned"); - expect(text).toContain("- npm:express@5.2.1\n Searched: code"); + expect(firstLine(text)).toBe("No results"); + expect(text).toContain("- npm:express@5.2.1\n searched: code"); expect(text).toContain( 'Next: shorten or broaden query; use source="symbol"; use code_grep.', ); - expect(text).toContain("Search search-ref-empty | completed"); + expect(text).not.toContain("Search search-ref-empty | completed"); + }); + + it("terminal target recovery renders typed guidance for stored results", () => { + const payload: UnifiedSearchStatusCompletedPayload = { + completed: true, + searchRef: "search-ref-terminal", + result: result({ + sourceStatus: [ + { + source: "CODE", + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + }, + { + source: "CODE", + targetLabel: "github:owner/repo#main", + indexingStatus: "UNRESOLVABLE", + targetResolution: { + freshness: "indexing", + freshnessReason: "no_current_fallback", + requested: { repoUrl: "https://github.com/owner/repo" }, + availableVersions: [], + availableRefs: [], + }, + }, + { + source: "DOCS", + targetLabel: "site:docs.example.com", + codeIndexState: "UNRESOLVABLE", + }, + { + source: "CODE", + targetLabel: "opaque:target", + indexingStatus: "NOT_FOUND", + }, + ], + }), + }; + const text = renderUnifiedSearchStatusText(payload); + + expect(text).toContain( + "Fix: verify registry coordinate/version; use its public GitHub repo for", + ); + expect(text).toContain("repo-wide search."); + expect(text).toContain("Fix: verify public GitHub repository/ref."); + expect(text).toContain("Fix: verify site host/path."); + expect(text).toContain("Fix: verify or replace target."); + expect(text).not.toContain("rerun search later"); + expect(text).not.toContain("searchRef="); }); it("continues completed mutable evidence through one status action", () => { @@ -145,7 +194,7 @@ describe("renderUnifiedSearchStatusText", () => { }; const text = renderUnifiedSearchStatusText(payload); expect(firstLine(text)).toContain("1 result"); - expect(text).toContain("Search search-ref-evidence | completed"); + expect(text).not.toContain("Search search-ref-evidence | completed"); expect(text).toContain( 'Next: search_status search_ref="search-ref-evidence" wait_timeout_ms=20000', ); @@ -167,7 +216,9 @@ describe("renderUnifiedSearchStatusText", () => { }, }), ); - expect(firstLine(text)).toStartWith(status); + expect(firstLine(text)).toBe( + `No result snapshot | ${status.toLowerCase()} | 0/1 ready`, + ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); @@ -186,7 +237,7 @@ describe("renderUnifiedSearchStatusText", () => { }), ); expect(firstLine(text)).toBe( - "FUTURE_SESSION_STATE - no result snapshot returned", + "No result snapshot | status unknown | 0/1 ready", ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("Do not poll"); diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index ae3fc47d..559b0d34 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -259,7 +259,7 @@ describe("renderUnifiedSearchSuccess", () => { "10 results | 5 repo docs, 5 docs pages | next_offset=10", ); expect(text).toContain( - "Sources: expressjs.com; expressjs/express@dbac741a", + "Sources: npm:express@5.2.1 - site:expressjs.com,\n github:expressjs/express#dbac741a", ); expect(text).toContain( "[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01", @@ -277,6 +277,172 @@ describe("renderUnifiedSearchSuccess", () => { expect(text.length).toBeLessThan(3459); }); + it("keeps contributor-less documentation in detailed target state", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "npm:express@5.2.1" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:express@5.2.1", + resultCount: 1, + }), + ], + }), + ); + + expect(text).toContain("- npm:express@5.2.1\n searched: docs"); + expect(text).not.toContain("Sources:"); + }); + + it("does not repeat a standalone site identity in compact output", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "site:hono.dev" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "site:hono.dev", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 1, + siteUrl: "https://hono.dev", + }, + ], + }), + ], + }), + ); + + expect(text).toContain("Sources: site:hono.dev"); + expect(text).not.toContain("site:hono.dev - site:hono.dev"); + }); + + it("uses the sole pinned repository source instead of its unpinned target", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "github:axios/axios" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "github:axios/axios", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/axios/axios", + commitSha: "fede1d1500000000000000000000000000000000", + }, + ], + }), + ], + }), + ); + + expect(text).toContain("Sources: github:axios/axios#fede1d15"); + expect(text).not.toContain( + "github:axios/axios - github:axios/axios#fede1d15", + ); + }); + + it("keeps a pinned repository target beside its resolved commit", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "github:axios/axios#v1.7.9" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "github:axios/axios#v1.7.9", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/axios/axios", + commitSha: "b2cb45d500000000000000000000000000000000", + }, + ], + }), + ], + }), + ); + + expect(text).toContain( + "Sources: github:axios/axios#v1.7.9 - github:axios/axios#b2cb45d5", + ); + }); + + it("keeps repository docs without a commit in detailed target state", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "github:axios/axios#main" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "github:axios/axios#main", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/axios/axios", + }, + ], + }), + ], + }), + ); + + expect(text).toContain( + "- github:axios/axios#main\n searched: repository docs", + ); + expect(text).not.toContain("Sources:"); + }); + + it("keeps concrete provenance grouped with each healthy target", () => { + const text = renderUnifiedSearchSuccess( + completed( + [ + docsHit({ target: "npm:one@1.0.0" }), + docsHit({ target: "npm:two@2.0.0" }), + ], + { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:one@1.0.0", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 1, + siteUrl: "https://docs.one.example", + }, + ], + }), + source({ + source: "docs", + targetLabel: "npm:two@2.0.0", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 1, + siteUrl: "https://docs.two.example", + }, + ], + }), + ], + }, + ), + { width: 160 }, + ); + + expect(text).toContain( + "Sources: npm:one@1.0.0 - site:docs.one.example; npm:two@2.0.0 - site:docs.two.example", + ); + expect(text).not.toContain("\n- npm:one@1.0.0"); + expect(text).not.toContain("\n- npm:two@2.0.0"); + }); + it("starts completed hits with the outcome and preserves hit anatomy", () => { const text = renderUnifiedSearchSuccess(completed([codeHit()])); @@ -291,6 +457,26 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("searchRef="); }); + it("does not append a separator when progress is not rendered after hits", () => { + const payload = completed([codeHit()]); + const presentation = projectUnifiedSearchPresentation(payload); + const text = renderUnifiedSearchPresentationText( + { + ...presentation, + progress: { targetsReady: 1, targetsTotal: 1, elapsedMs: 20 }, + }, + { results: payload.results }, + ); + + expect(text).toBe( + "1 result | 1 repo code hit\n\n" + + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] -\n" + + " applyEdit\n" + + " Search/replace block parser with fuzzy fallback when exact match fails.", + ); + expect(text.endsWith("\n")).toBe(false); + }); + it("uses singular labels for one repository doc and one docs page", () => { const repoText = renderUnifiedSearchSuccess( completed([ @@ -565,8 +751,8 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toContain("No results returned"); - expect(text).toContain("\n- npm:express@5.2.1\n Searched: code"); + expect(firstLine(text)).toBe("No results"); + expect(text).toContain("\n- npm:express@5.2.1\n searched: code"); expect(text).toContain( 'Next: shorten or broaden query; remove restrictive filters; use source="symbol"; use code_grep.', ); @@ -574,7 +760,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("Do not repeat"); }); - it("renders symbol source readiness as code", () => { + it("source and warning provenance renders symbol readiness separately", () => { const text = renderUnifiedSearchSuccess( completed([], { query: { raw: "router", sources: ["symbol"] }, @@ -588,10 +774,186 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("Searched: code"); + expect(text).toContain("searched: symbols"); expect(text).not.toContain("repository docs"); }); + it("source and warning provenance renders mixed code and symbol readiness", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ source: "CODE", codeIndexState: "CURRENT" }), + source({ source: "SYMBOL", codeIndexState: "CURRENT" }), + ], + }), + ); + + expect(text).toContain("searched: code, symbols"); + }); + + it("source and warning provenance renders lane and target attribution", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + source: "DOCS", + targetLabel: "npm:express", + ignoredFilters: ["fileIntent"], + }), + source({ + source: "SYMBOL", + targetLabel: "npm:express", + ignoredQueryFeatures: ["name"], + }), + source({ + source: "Future-Lane", + targetLabel: "opaque-target", + incompatibleFilters: ["lang"], + }), + ], + }), + ); + + expect(text).toContain("ignored filter (docs): fileIntent"); + expect(text).toContain("ignored query\n feature (symbol): name"); + expect(text).toContain("incompatible filter (future-lane): lang"); + }); + + it("terminal target recovery renders one positive line per target family", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + }), + source({ + targetLabel: "github:owner/repo#main", + indexingStatus: "UNRESOLVABLE", + targetResolution: { + freshness: "indexing", + freshnessReason: "no_current_fallback", + requested: { repoUrl: "https://github.com/owner/repo" }, + availableVersions: [], + availableRefs: [], + }, + }), + source({ + source: "docs", + targetLabel: "site:docs.example.com", + codeIndexState: "UNRESOLVABLE", + }), + source({ + targetLabel: "opaque:target", + indexingStatus: "NOT_FOUND", + }), + ], + }), + ); + + expect(text).toContain( + "Fix: verify registry coordinate/version; use its public GitHub repo for", + ); + expect(text).toContain("repo-wide search."); + expect(text).toContain("Fix: verify public GitHub repository/ref."); + expect(text).toContain("Fix: verify site host/path."); + expect(text).toContain("Fix: verify or replace target."); + expect(text).not.toContain("rerun search later"); + expect(text).not.toContain("searchRef"); + expect(text.match(/Fix:/g)).toHaveLength(4); + + const cliText = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + }), + ], + }), + { actionSyntax: "cli" }, + ); + expect(cliText).toContain( + "Fix: verify registry coordinate/version; use its public GitHub repo for", + ); + expect(cliText).toContain("repo-wide search."); + expect(cliText).not.toContain("search_status"); + }); + + it("terminal target recovery uses package guidance for a registry target with repo resolution", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + targetResolution: { + requested: { repoUrl: "https://github.com/expressjs/express" }, + availableVersions: [], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(text).toContain( + "Fix: verify registry coordinate/version; use its public GitHub repo for", + ); + expect(text).not.toContain("Fix: verify public GitHub repository/ref."); + }); + + it("renders a canonical recovery target for a latest package display identity", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:express latest", + requestedTarget: "npm:express latest", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + availableVersions: [{ version: "5.1.0", ref: "v5.1.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(text).toBe( + "No results\n\n" + + "- npm:express latest\n" + + " package unresolved: code\n" + + " Try: npm:express@5.1.0", + ); + expect(text).not.toContain("npm:express latest@5.1.0"); + }); + + it("renders terminal peer recovery beside healthy hits without a global Next", () => { + const text = renderUnifiedSearchSuccess( + completed([codeHit({ target: "npm:express@4.18.2" })], { + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "CURRENT", + resultCount: 1, + }), + source({ + targetLabel: "npm:missing@1.0.0", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + ], + }), + ); + + expect(text).toContain("- npm:missing@1.0.0"); + expect(text).toContain("Fix: verify registry coordinate/version"); + expect(text).not.toContain("Next:"); + }); + it.each(["docs", "auto"] as const)( "uses a neutral docs label for contributor-less %s sources", (sourceName) => { @@ -601,7 +963,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("- npm:express@4.18.2\n Searched: docs"); + expect(text).toContain("- npm:express@4.18.2\n searched: docs"); expect(text).not.toContain("repository docs"); }, ); @@ -610,19 +972,18 @@ describe("renderUnifiedSearchSuccess", () => { const text = renderUnifiedSearchSuccess(n8nActiveEmpty()); expect(text).toBe( - "Indexing - no results yet\n\n" + + "No results yet | indexing | 0/1 ready\n\n" + "- npm:n8n -> 2.36.7\n" + - " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages;\n" + - " capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master\n\n" + - "Search fabUr1S3MEVeSgD93pMoSQ | 0/1 target ready\n" + + " indexing: code, repository docs; available: n8n.io docs (1,480 pages; capped);\n" + + " indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master\n\n" + 'Next: search_status search_ref="fabUr1S3MEVeSgD93pMoSQ" wait_timeout_ms=20000', ); expect(text).not.toContain("Do not repeat"); expect(text).not.toContain("indexingRef"); expect(text).not.toContain("freshnessReason"); expect(text).not.toContain("Opaque evidence notice"); - expect(text.match(/Indexing/g)).toHaveLength(2); - expect(text.match(/Available now:/g)).toHaveLength(1); + expect(text.match(/indexing/g)).toHaveLength(2); + expect(text.match(/available:/g)).toHaveLength(1); expect(text.match(/Next:/g)).toHaveLength(1); }); @@ -709,8 +1070,12 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("Indexing - no result snapshot yet"); - expect(text).toContain("Search ref_abc-123 | 0/2 targets ready"); + expect(firstLine(text)).toBe( + "No result snapshot yet | indexing | 0/2 ready", + ); + expect(text).toContain( + 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', + ); }); it("does not invent source details for a true progress-only response", () => { @@ -733,12 +1098,13 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("Indexing - no result snapshot yet"); - expect(text).toContain("Search ref_abc-123 | 0/1 target ready"); + expect(firstLine(text)).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(text).not.toContain("Waiting:"); expect(text).not.toContain("Searched:"); expect(text).not.toContain("n8n.io"); - expect(text).toContain("Status: indexing | Available now: versions 2.26.9"); + expect(text).toContain("indexed: versions 2.26.9"); expect(text).toContain("versions 2.26.9"); expect(text).toContain( 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', @@ -763,11 +1129,11 @@ describe("renderUnifiedSearchSuccess", () => { }); it.each([ - ["CURRENT", "Status: ready"], - ["INDEXED", "Status: ready"], - ["PENDING", "Status: pending"], - ["INDEXING", "Status: indexing"], - ["PROVISIONAL", "Status: provisional"], + ["CURRENT", "ready"], + ["INDEXED", "ready"], + ["PENDING", "pending"], + ["INDEXING", "indexing"], + ["PROVISIONAL", "provisional"], ] as const)( "renders explicit target freshness %s accurately", (freshness, detail) => { @@ -814,9 +1180,11 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain("- npm:express@5.1.0"); - expect(text).toContain("- npm:express -> 5.2.1"); + expect(text).toContain("- npm:express"); expect(text.match(/^- npm:express/gm)).toHaveLength(2); - expect(text).toContain("Search ref_abc-123 | 0/2 targets ready"); + expect(text).toContain( + 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', + ); }); it("renders an initial progress-only parser warning once below the outcome", () => { @@ -826,7 +1194,9 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("Indexing - no result snapshot yet"); + expect(firstLine(text)).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(text).toContain("Warnings:\n - unknown qualifier"); expect(text.match(/unknown qualifier/g)).toHaveLength(1); expect(text.indexOf("Warnings:")).toBeGreaterThan(0); @@ -861,7 +1231,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "Searched: example.com/reference docs | Available now: example.com/guide docs", + "searched: example.com/reference docs; available: example.com/guide docs", ); expect(text).not.toContain("not searched"); expect(text).not.toContain("for npm:example@1.0.0"); @@ -881,14 +1251,13 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "Suggested sites: site:docs.example.com,\n site:api.example.com | More suggested sites omitted", + "available: site:docs.example.com,\n site:api.example.com", ); expect(text).toContain( 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', ); expect(text).not.toContain("Next: retry one suggested site target"); - expect(text.match(/Suggested sites:/g)).toHaveLength(1); - expect(text.match(/More suggested sites omitted/g)).toHaveLength(1); + expect(text.match(/available:/g)).toHaveLength(1); }); it("does not suffix deduplicated site suggestions with a target", () => { @@ -908,8 +1277,8 @@ describe("renderUnifiedSearchSuccess", () => { incomplete({ partialResults: false, sourceStatus }), ); - expect(text).toContain("Suggested sites: site:docs.example.com"); - expect(text).not.toContain("Suggested sites for site:example.com:"); + expect(text).toContain("available: site:docs.example.com"); + expect(text).not.toContain("available: site:example.com"); }); it("renders site retry guidance for completed and terminal site recovery", () => { @@ -924,10 +1293,7 @@ describe("renderUnifiedSearchSuccess", () => { const completedText = renderUnifiedSearchSuccess( completed([], { sourceStatus }), ); - expect(completedText).toContain("Suggested sites: site:docs.example.com"); - expect(completedText).toContain( - "Next: retry one suggested site target explicitly.", - ); + expect(completedText).toContain("Try: site:docs.example.com"); expect(completedText).not.toContain("search_status"); const terminalText = renderUnifiedSearchSuccess( @@ -942,10 +1308,7 @@ describe("renderUnifiedSearchSuccess", () => { }, }), ); - expect(terminalText).toContain("Suggested sites: site:docs.example.com"); - expect(terminalText).toContain( - "Next: retry one suggested site target explicitly.", - ); + expect(terminalText).toContain("Try: site:docs.example.com"); expect(terminalText).not.toContain("Next: search_status"); }); @@ -1005,12 +1368,12 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("No results returned"); + expect(firstLine(text)).toBe("No results"); expect(text).toContain( - "- npm:one@1.0.0\n Indexing: code | Searched: repository docs, docs.one.example docs", + "- npm:one@1.0.0\n searched: repository docs, docs.one.example docs; indexing: code", ); expect(text).toContain( - "- npm:two@2.0.0\n Indexing: code | Searched: repository docs, docs.two.example docs", + "- npm:two@2.0.0\n searched: repository docs, docs.two.example docs; indexing: code", ); }); @@ -1038,9 +1401,9 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("- npm:one@1.0.0\n Indexing: code"); + expect(text).toContain("- npm:one@1.0.0\n indexing: code"); expect(text).toContain( - "- site:docs.one.example\n Searched: site:docs.one.example docs", + "- site:docs.one.example\n searched: site:docs.one.example docs", ); expect(text).not.toContain("for site:"); }); @@ -1073,17 +1436,140 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("- npm:one@1.0.0\n Unavailable: code"); + expect(text).toContain("- npm:one@1.0.0\n unavailable: code"); expect(text).not.toContain( - "Unavailable: code (npm:one@1.0.0) for npm:one@1.0.0", + "unavailable: code (npm:one@1.0.0) for npm:one@1.0.0", ); - expect(text).toContain("- npm:two@2.0.0\n Searched: code"); + expect(text).toContain("- npm:two@2.0.0\n searched: code"); expect(text).toContain( - "- site:docs.one.example\n Available now: site:docs.one.example docs", + "- site:docs.one.example\n available: site:docs.one.example docs", ); expect(text).not.toContain("for site:"); }); + it("keeps terminal lane reasons bare beside searched evidence and rewrites once", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ source: "code", codeIndexState: "CURRENT" }), + source({ source: "symbol", codeIndexState: "NOT_FOUND" }), + ], + }), + ); + + expect(text).toContain("searched: code; not found: symbols"); + expect(text).not.toContain("Fix:"); + expect(text).toContain( + 'Next: shorten or broaden query; use source="symbol"; use code_grep.', + ); + }); + + it("keeps a bare terminal reason and rerun action for terminal mixed lanes", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + partialResults: false, + progress: { + status: "FAILED", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + sourceStatus: [ + source({ + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + source: "symbol", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + ], + }), + ); + + expect(text).toBe( + "No results | failed | 0/1 ready\n\n" + + "- npm:express@4.18.2\n" + + " searched: code; not found: symbols\n\n" + + "Next: rerun search later.", + ); + }); + + it("prefers rerun when a completed-empty peer target is still indexing", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:one@1.0.0", + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + targetLabel: "npm:one@1.0.0", + source: "symbol", + codeIndexState: "UNRESOLVABLE", + resultCount: 0, + }), + source({ + targetLabel: "npm:two@2.0.0", + source: "code", + codeIndexState: "INDEXING", + resultCount: 0, + }), + ], + }), + ); + + expect(text).toBe( + "No results\n\n" + + "- npm:one@1.0.0\n" + + " searched: code; unresolved: symbols\n\n" + + "- npm:two@2.0.0\n" + + " indexing: code\n\n" + + "Next: rerun search later.", + ); + }); + + it("keeps terminal alternatives informational beside a bare lane reason", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + partialResults: false, + progress: { + status: "FAILED", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + sourceStatus: [ + source({ + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + source: "symbol", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + availableVersions: [{ version: "4.17.0", ref: "v4.17.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(text).toBe( + "No results | failed | 0/1 ready\n\n" + + "- npm:express@4.18.2\n" + + " searched: code; unresolved: symbols; indexed: versions 4.17.0\n\n" + + "Next: rerun search later.", + ); + }); + it("omits a singular outcome target when hits span multiple targets", () => { const text = renderUnifiedSearchSuccess( completed([ @@ -1097,9 +1583,9 @@ describe("renderUnifiedSearchSuccess", () => { }); it.each([ - ["PENDING", "Preparing"], - ["INDEXING", "Indexing"], - ["SEARCHING", "Searching"], + ["PENDING", "preparing"], + ["INDEXING", "indexing"], + ["SEARCHING", "searching"], ] as const)( "keeps %s lifecycle distinct without a snapshot", (status, label) => { @@ -1113,9 +1599,9 @@ describe("renderUnifiedSearchSuccess", () => { }, }), ); - expect(firstLine(text)).toStartWith(label); - expect(firstLine(text)).toContain("no result snapshot yet"); - expect(firstLine(text)).not.toContain("No results yet"); + expect(firstLine(text)).toBe( + `No result snapshot yet | ${label} | 0/1 ready`, + ); }, ); @@ -1155,7 +1641,9 @@ describe("renderUnifiedSearchSuccess", () => { }, }), ); - expect(firstLine(text)).toStartWith(status); + expect(firstLine(text)).toBe( + `No result snapshot | ${status.toLowerCase()} | 0/1 ready`, + ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); @@ -1174,12 +1662,12 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(firstLine(text)).toBe( - "FUTURE_SESSION_STATE - no result snapshot returned", + "No result snapshot | status unknown | 1/2 ready", ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); - expect(text).not.toContain("indexing"); + expect(text).not.toContain("FUTURE_SESSION_STATE"); }); it("renders stale and provisional evidence as trust limits without raw diagnostics", () => { @@ -1203,8 +1691,8 @@ describe("renderUnifiedSearchSuccess", () => { ], }), ); - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes | Searched: code"); + expect(text).toContain("- npm:express latest"); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes; searched: code"); expect(text).not.toContain("Evidence:"); expect(text).not.toContain("idx-hidden"); expect(text).not.toContain("exact_provisional"); @@ -1238,9 +1726,9 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(firstLine(text)).toBe("1 result | 1 repo code hit"); - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text.match(/Using:/g)).toHaveLength(1); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text).toContain("- npm:express latest"); + expect(text.match(/using:/g)).toHaveLength(1); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes"); }); it("treats indexing hit freshness as stale served evidence", () => { @@ -1257,9 +1745,9 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(firstLine(text)).toBe("1 result | 1 repo code hit"); - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text.match(/Using:/g)).toHaveLength(1); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text).toContain("- npm:express latest"); + expect(text.match(/using:/g)).toHaveLength(1); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes"); }); it("shows a served older version from progress-only stale identity", () => { @@ -1282,9 +1770,9 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text.match(/Using:/g)).toHaveLength(1); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text).toContain("- npm:express latest"); + expect(text.match(/using:/g)).toHaveLength(1); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes"); expect(text).not.toContain("(using"); }); @@ -1308,7 +1796,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "- npm:express latest\n Using: 5.1.0 (older snapshot)", + "- npm:express latest\n using: 5.1.0 (older snapshot)", ); expect(text).not.toContain("- npm:express latest -> 5.1.0"); }); @@ -1334,7 +1822,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("No results returned"); + expect(firstLine(text)).toBe("No results"); }); it.each([ @@ -1367,7 +1855,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("No results returned"); + expect(firstLine(text)).toBe("No results"); expect(firstLine(text)).not.toContain(targetLabel); expect(firstLine(text)).not.toContain(freshTarget); }, @@ -1380,8 +1868,7 @@ describe("renderUnifiedSearchSuccess", () => { evidenceNotice: "Opaque backend prose must not be copied.", }), ); - expect(firstLine(text)).toBe("No results returned"); - expect(text).toContain("Search search-ref-evidence | completed"); + expect(firstLine(text)).toBe("No results"); expect(text).toContain( 'Next: search_status search_ref="search-ref-evidence" wait_timeout_ms=20000', ); @@ -1398,14 +1885,14 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(firstLine(text)).toContain("1 result"); - expect(text).toContain("Search search-ref-results | completed"); expect(text).toContain( 'Next: search_status search_ref="search-ref-results" wait_timeout_ms=20000', ); const lines = text.split("\n"); const actionLine = lines.findIndex((line) => line.startsWith("Next: ")); expect(actionLine).toBeGreaterThan(0); - expect(lines[actionLine - 1]).toBe("Search search-ref-results | completed"); + expect(lines[actionLine - 1]).toBe(""); + expect(text).toContain("Search/replace block parser"); expect(text).not.toContain("Evidence may change."); expect(text).not.toContain("Do not repeat"); }); @@ -1426,11 +1913,11 @@ describe("renderUnifiedSearchSuccess", () => { ], }), ); - expect(firstLine(text)).toContain("No results returned"); + expect(firstLine(text)).toBe("No results"); expect(text).toContain("Warnings:"); expect(text).toContain("kind was ignored by the selected source"); - expect(text).toContain("Incompatible query feature"); - expect(text).toContain("Ignored filter"); + expect(text).toContain("incompatible query feature\n (code): kind"); + expect(text).toContain("ignored filter (code): category"); expect(text).not.toContain("duplicated promoted warning"); expect(text.match(/Warnings:/g)).toHaveLength(1); }); @@ -1475,7 +1962,7 @@ describe("renderUnifiedSearchSuccess", () => { ], }), ); - expect(text).toContain("Next: search indexed version 5.1.0"); + expect(text).toContain("Try: npm:express@5.1.0"); expect(text).not.toContain("shorten or broaden query"); expect(text).not.toContain("code_grep"); }); @@ -1532,7 +2019,7 @@ describe("renderUnifiedSearchSuccess", () => { "[2] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html -\n Edit Formats", ); expect(text).toContain( - "Available now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", + "indexed: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD, main,", ); expect(text).toContain("next_offset=10"); expect(cliText).toContain("next_offset=10"); @@ -1550,7 +2037,7 @@ describe("renderUnifiedSearchSuccess", () => { }); expect(presentation.hasMore).toBe(true); - expect(text).toContain("No results returned | next_offset=10"); + expect(text).toContain("No results | next_offset=10"); }); it("keeps pagination in active and terminal result headlines", () => { @@ -1622,21 +2109,16 @@ describe("renderUnifiedSearchSuccess", () => { const lines = text.split("\n"); const summaryLines = lines.filter((line) => - /^( {2})?(Indexing|Searched|Available now|Suggested sites)/.test(line), + /^( {2})?(indexing|searched|available|indexed)/.test(line), ); expect(summaryLines.length).toBeGreaterThanOrEqual(3); expect(summaryLines.every((line) => line.length <= 80)).toBe(true); expect(text).toContain(targetOne); expect(text).toContain(targetTwo); - expect(text).toContain(longRef); - expect(text).toContain("More suggested sites omitted"); - expect(text).toContain( - "Next: search indexed version 1.0.0 for npm:one-long-package@1.0.0.", - ); + expect(text).not.toContain(longRef); + expect(text).toContain("Try: npm:one-long-package@1.0.0"); - const overlongLines = lines.filter((line) => line.length > 80); - expect(overlongLines).toHaveLength(1); - expect(overlongLines[0]).toContain(longRef); + expect(lines.every((line) => line.length <= 80)).toBe(true); }); it("wraps target details at the caller-supplied full output width", () => { @@ -1650,7 +2132,9 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(detailLines(narrow).every((line) => line.length <= 60)).toBe(true); expect(detailLines(wide).every((line) => line.length <= 140)).toBe(true); - expect(wide).toContain("n8n.io docs (1,480 pages; capped), versions"); + expect(wide).toContain( + "n8n.io docs (1,480 pages; capped); indexed: versions", + ); }); it("shows capped searched coverage without repeating the trust limit", () => { @@ -1679,7 +2163,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(text).toContain( - "Searched: docs.example.com docs (120 pages; partial)", + "searched: docs.example.com docs (120 pages; partial)", ); expect(text.match(/120 pages/g)).toHaveLength(1); }); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index f5d0c96f..519347a5 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -17,6 +17,7 @@ import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; import { colors, dim, highlight, highlightRanges } from "./colors.js"; +import { formatRepositoryTarget } from "./repository-target.js"; import { projectUnifiedSearchPresentation, targetDisplayFamilyKey, @@ -27,6 +28,8 @@ import { type UnifiedSearchSourceGroup, type UnifiedSearchSourceKind, type UnifiedSearchTargetGroup, + type UnifiedSearchTargetRecovery, + type UnifiedSearchTerminalReason, type UnifiedSearchTrustLimit, type UnifiedSearchWarning, } from "./unified-search-presentation.js"; @@ -92,19 +95,6 @@ export function renderUnifiedSearchPresentationText( appendUnifiedSearchHits(lines, result.results, settings); } - const hasPostResultBlock = - presentation.searchRef !== undefined || - presentation.progress !== undefined || - presentation.action.kind !== "none"; - if ( - result.results.length > 0 && - hasPostResultBlock && - lines[lines.length - 1] !== "" - ) { - lines.push(""); - } - - appendPresentationSession(lines, presentation, settings); appendPresentationAction(lines, presentation, settings); return lines.join("\n"); } @@ -134,8 +124,6 @@ function formatPresentationOutcome( nextOffset: number | undefined, options: NormalizedTextOptions, ): string { - const target = presentationTarget(presentation, results); - const targetSuffix = target ? ` ${target}` : ""; const count = presentation.availability.resultCount; const countLabel = `${count} result${count === 1 ? "" : "s"}`; const finish = (value: string): string => @@ -147,16 +135,30 @@ function formatPresentationOutcome( if (presentation.lifecycle.kind === "active") { const label = activeLifecycleLabel(presentation.lifecycle); + const readiness = presentation.progress + ? `${presentation.progress.targetsReady}/${presentation.progress.targetsTotal} ready` + : undefined; if (presentation.availability.kind === "no_snapshot") { - return finish(`${label}${targetSuffix} - no result snapshot yet`); + return finish( + ["No result snapshot yet", label, readiness].filter(Boolean).join(SEP), + ); } if (presentation.availability.kind === "empty") { - return finish(`${label}${targetSuffix} - no results yet`); + return finish( + ["No results yet", label, readiness].filter(Boolean).join(SEP), + ); } const resultKind = presentation.availability.kind === "partial" ? "partial" : "interim"; return finish( - `${label} continues - ${countLabel.replace("result", `${resultKind} result`)} returned`, + [ + countLabel.replace("result", `${resultKind} result`), + formatResultBreakdown(results), + label, + readiness, + ] + .filter(Boolean) + .join(SEP), ); } @@ -164,16 +166,27 @@ function formatPresentationOutcome( return finish( count > 0 ? formatCompletedResultsHeadline(results, countLabel) - : `No results returned${target ? ` from ${target}` : ""}`, + : "No results", ); } - const status = presentation.lifecycle.status ?? "UNKNOWN"; - if (count > 0) return finish(`${status} - ${countLabel} returned`); + const status = formatLifecycleSummary(presentation.lifecycle); + const readiness = presentation.progress + ? `${presentation.progress.targetsReady}/${presentation.progress.targetsTotal} ready` + : undefined; + if (count > 0) { + return finish( + [countLabel, formatResultBreakdown(results), status, readiness] + .filter(Boolean) + .join(SEP), + ); + } if (presentation.availability.kind === "no_snapshot") { - return finish(`${status} - no result snapshot returned`); + return finish( + ["No result snapshot", status, readiness].filter(Boolean).join(SEP), + ); } - return finish(`${status} - no results returned`); + return finish(["No results", status, readiness].filter(Boolean).join(SEP)); } function formatCompletedResultsHeadline( @@ -261,41 +274,12 @@ function activeLifecycleLabel( ): string { switch (lifecycle.status) { case "PENDING": - return "Preparing"; + return "preparing"; case "INDEXING": - return "Indexing"; + return "indexing"; case "SEARCHING": - return "Searching"; - } -} - -function presentationTarget( - presentation: UnifiedSearchPresentation, - results: UnifiedSearchHitPayload[], -): string | undefined { - if (presentation.targetGroups.length > 0) return undefined; - if (presentation.targets.length > 1) return undefined; - if (results.length > 0) { - const sourceTargets = presentation.sources.flatMap((group) => - group.entries.map((entry) => entry.searchTarget), - ); - const identities = [ - ...results.map((result) => result.target), - ...sourceTargets, - ]; - if (new Set(identities).size > 1) return undefined; - return results[0]?.target; - } - if (presentation.targets.length === 1) { - const target = presentation.targets[0]; - return target?.served ?? target?.fresh ?? target?.requested; + return "searching"; } - const sourceTargets = presentation.sources.flatMap((group) => - group.entries.map((entry) => entry.searchTarget), - ); - if (new Set(sourceTargets).size > 1) return undefined; - const source = presentation.sources[0]?.entries[0]; - return source?.searchTarget ?? source?.target; } function appendPresentationContext( @@ -305,7 +289,7 @@ function appendPresentationContext( ): void { if (shouldRenderCompactSources(presentation)) { lines.push(""); - appendCompactSources(lines, presentation.sources, options); + appendCompactSources(lines, presentation.targetGroups, options); } else if (presentation.targetGroups.length > 0) { lines.push(""); presentation.targetGroups.forEach((group, index) => { @@ -322,11 +306,7 @@ function shouldRenderCompactSources( if ( presentation.lifecycle.kind !== "completed" || presentation.availability.resultCount === 0 || - presentation.sources.length === 0 || - presentation.targetGroups.length === 0 || - presentation.alternatives.length > 0 || - presentation.trustLimits.length > 0 || - presentation.siteSuggestions.length > 0 + presentation.targetGroups.length === 0 ) { return false; } @@ -335,93 +315,116 @@ function shouldRenderCompactSources( group.alternatives === undefined && group.siteSuggestions.length === 0 && group.trustLimits.length === 0 && + group.recovery === undefined && (group.freshnessKind === undefined || group.freshnessKind === "current") && group.sources.every((source) => - source.entries.every((entry) => entry.state === "searched"), + source.entries.every( + (entry) => + entry.state === "searched" && + formatCompactSource(source.kind, entry) !== undefined, + ), ), ); } function appendCompactSources( lines: string[], - sources: UnifiedSearchSourceGroup[], + groups: UnifiedSearchTargetGroup[], options: NormalizedTextOptions, ): void { - const values = sources - .flatMap((source) => - source.entries.map((entry) => ({ - rank: compactSourceRank(source.kind), - value: formatCompactSource(source.kind, entry), - })), - ) - .filter( - (entry): entry is { rank: number; value: string } => - entry.value.length > 0, - ) - .sort((left, right) => left.rank - right.rank) - .map((entry) => entry.value); + const values = groups.flatMap((group) => { + const identity = + group.identity.served ?? group.identity.fresh ?? group.identity.requested; + if (!identity) return []; + const sources = group.sources + .flatMap((source) => + source.entries + .filter((entry) => entry.state === "searched") + .flatMap((entry) => { + const value = formatCompactSource(source.kind, entry); + return value + ? [{ rank: compactSourceRank(source.kind), value }] + : []; + }), + ) + .sort((left, right) => left.rank - right.rank) + .map((source) => source.value); + const uniqueSources = [...new Set(sources)]; + const distinctSources = uniqueSources.filter( + (source) => source !== identity, + ); + if (distinctSources.length === 0) return [identity]; + if ( + uniqueSources.length === 1 && + !identity.includes("#") && + targetDisplayFamilyKey(distinctSources[0]) === + targetDisplayFamilyKey(identity) + ) { + return distinctSources; + } + return [`${identity} - ${distinctSources.join(", ")}`]; + }); const unique = [...new Set(values)]; if (unique.length === 0) return; - lines.push(...wrapText(`Sources: ${unique.join("; ")}`, options.width)); + const wrapped = wrapText( + unique.join("; "), + Math.max(1, options.width - "Sources: ".length), + ); + lines.push( + `Sources: ${wrapped[0] ?? ""}`, + ...wrapped.slice(1).map((line) => ` ${line}`), + ); } function compactSourceRank(kind: UnifiedSearchSourceKind): number { switch (kind) { - case "site_docs": + case "code": return 0; - case "repository_docs": + case "symbols": return 1; - case "docs": + case "site_docs": return 2; - case "code": + case "repository_docs": return 3; + case "docs": + return 4; } } function formatCompactSource( kind: UnifiedSearchSourceKind, entry: UnifiedSearchSourceEntry, -): string { - if (kind === "site_docs") { - return ( - formatDocumentationSiteIdentity(entry.siteUrl) ?? - entry.siteKey ?? - compactTarget(entry.target) +): string | undefined { + if (kind === "code") return "code"; + if (kind === "symbols") return "symbols"; + if (kind === "repository_docs" && entry.repositoryUrl && entry.commitSha) { + return formatRepositoryTarget( + entry.repositoryUrl, + entry.commitSha.slice(0, 8), ); } - if (entry.repositoryUrl) { - return formatRepositoryIdentity(entry.repositoryUrl, entry.commitSha); - } - if (entry.siteUrl) { - return formatDocumentationSiteIdentity(entry.siteUrl) ?? entry.siteUrl; + if (kind === "site_docs") { + const siteIdentity = formatDocumentationSiteIdentity(entry.siteUrl); + if (siteIdentity) return `site:${siteIdentity}`; + if (entry.target.startsWith("site:")) return entry.target; } - return compactTarget(entry.target); + return undefined; } -function formatRepositoryIdentity(url: string, commitSha?: string): string { - let identity = url; - try { - const parsed = new URL(url); - const path = parsed.pathname - .split("/") - .filter(Boolean) - .join("/") - .replace(/\.git$/, ""); - identity = - parsed.host === "github.com" && path ? path : `${parsed.host}/${path}`; - } catch { - identity = url.replace(/^https?:\/\//, "").replace(/\.git$/, ""); +function sourceKindRank(kind: UnifiedSearchSourceKind): number { + switch (kind) { + case "code": + return 0; + case "symbols": + return 1; + case "repository_docs": + return 2; + case "site_docs": + return 3; + case "docs": + return 4; } - if (!commitSha) return identity; - return `${identity}@${commitSha.slice(0, 8)}`; -} - -function compactTarget(value: string): string { - return value - .replace(/^site:/, "") - .replace(/^github:/, "") - .replace(/@[^/@#]+$/, ""); } function appendPresentationTargetGroup( @@ -433,6 +436,65 @@ function appendPresentationTargetGroup( lines.push(options.useColors ? highlight(identity, true) : identity); const details: string[] = []; + const using = formatUsingSegment(group); + if (using) details.push(using); + + const searched = formatSourceStateSegment(group, "searched"); + if (searched) details.push(`searched: ${searched}`); + const indexing = formatSourceStateSegment(group, "waiting"); + if (indexing) details.push(`indexing: ${indexing}`); + + const unavailable = formatUnavailableSegment(group); + if (unavailable) details.push(unavailable); + + const available = formatAvailableSegment(group); + if (available) details.push(`available: ${available}`); + + if (group.recovery === undefined) { + const indexed = formatTargetAlternatives(group.alternatives); + if (indexed) details.push(`indexed: ${indexed}`); + } + + const constraints = formatTargetConstraints(group); + if (constraints) details.push(constraints); + + if (details.length === 0 && group.freshnessKind !== undefined) { + details.push(formatTargetStatus(group.freshnessKind)); + } + + if (details.length > 0) { + lines.push(...wrapHangingText(details.join("; "), " ", options.width)); + } + if (group.recovery) { + const recovery = formatTargetRecovery(group.recovery, group); + lines.push( + ...wrapHangingText(recovery, " ", options.width).map((line) => + options.useColors ? `${colors.yellow}${line}${colors.reset}` : line, + ), + ); + } +} + +function formatTargetStatus( + freshness: NonNullable, +): string { + switch (freshness) { + case "current": + return "ready"; + case "pending": + return "pending"; + case "provisional": + return "provisional"; + case "stale": + return "older snapshot"; + case "indexing": + return "indexing"; + } +} + +function formatUsingSegment( + group: UnifiedSearchTargetGroup, +): string | undefined { const stale = group.trustLimits .filter( (limit): limit is Extract => @@ -451,83 +513,156 @@ function appendPresentationTargetGroup( (group.freshnessKind === "stale" || group.freshnessKind === "indexing") && group.identity.served !== (group.identity.fresh ?? group.identity.requested); - if (stale || identityIsStale) { - const served = - stale?.servedTarget ?? stale?.target ?? group.identity.served; - const fresh = stale?.freshTarget ?? group.identity.fresh; - details.push( - `Using: ${compactRelatedTarget(group.identity.requested, served ?? "older snapshot")}${fresh ? ` while ${compactRelatedTarget(group.identity.requested, fresh)} indexes` : " (older snapshot)"}`, - ); - } else if (group.trustLimits.some((limit) => limit.kind === "provisional")) { - details.push("Indexing: provisional snapshot is searchable"); + if (!stale && !identityIsStale) { + return group.trustLimits.some((limit) => limit.kind === "provisional") + ? "using: provisional snapshot" + : undefined; } + const served = stale?.servedTarget ?? stale?.target ?? group.identity.served; + const fresh = stale?.freshTarget ?? group.identity.fresh; + return `using: ${compactRelatedTarget(group.identity.requested, served ?? "older snapshot")}${fresh ? ` while ${compactRelatedTarget(group.identity.requested, fresh)} indexes` : " (older snapshot)"}`; +} - const states: Array<{ - state: UnifiedSearchSourceEntry["state"]; - label: string; - }> = [ - { state: "waiting", label: "Indexing" }, - { state: "searched", label: "Searched" }, - { state: "available_not_searched", label: "Available now" }, - { state: "unavailable", label: "Unavailable" }, - ]; - for (const { state, label } of states) { - const entries = group.sources.flatMap((source) => +function formatSourceStateSegment( + group: UnifiedSearchTargetGroup, + state: UnifiedSearchSourceEntry["state"], +): string | undefined { + const values = group.sources + .flatMap((source) => source.entries .filter((entry) => entry.state === state) - .map((entry) => ({ source, entry })), - ); - if (entries.length === 0) continue; - const values = entries.map(({ source, entry }) => - formatGroupedSource(source, entry, group.trustLimits), - ); - details.push(`${label}: ${[...new Set(values)].join(", ")}`); - } + .map((entry) => ({ + rank: sourceKindRank(source.kind), + value: formatGroupedSource(source, entry, group.trustLimits), + })), + ) + .sort((left, right) => left.rank - right.rank) + .map((entry) => entry.value); + const unique = [...new Set(values)]; + return unique.length > 0 ? unique.join(", ") : undefined; +} - if (details.length === 0 && group.freshnessKind !== undefined) { - details.push(`Status: ${formatTargetStatus(group.freshnessKind)}`); - } +function formatUnavailableSegment( + group: UnifiedSearchTargetGroup, +): string | undefined { + const entries = group.sources.flatMap((source) => + source.entries + .filter((entry) => entry.state === "unavailable") + .map((entry) => ({ source, entry })), + ); + if (entries.length === 0) return undefined; + const mixed = group.sources.some((source) => + source.entries.some( + (entry) => entry.state === "searched" || entry.state === "waiting", + ), + ); + const values = entries + .map(({ source, entry }) => { + const lane = formatGroupedSource(source, entry, group.trustLimits); + const reason = entry.terminalReason; + const value = reason + ? `${formatTerminalReason(reason, mixed)}: ${lane}` + : `unavailable: ${lane}`; + return { rank: sourceKindRank(source.kind), value }; + }) + .sort((left, right) => left.rank - right.rank) + .map((entry) => entry.value); + return [...new Set(values)].join("; "); +} - const ready = formatTargetAlternatives(group.alternatives); - if (ready) { - const readyIndex = details.findIndex((detail) => - detail.startsWith("Available now:"), +function formatAvailableSegment( + group: UnifiedSearchTargetGroup, +): string | undefined { + const values = group.sources + .flatMap((source) => + source.entries + .filter((entry) => entry.state === "available_not_searched") + .map((entry) => ({ + rank: sourceKindRank(source.kind), + value: formatGroupedSource(source, entry, group.trustLimits), + })), + ) + .sort((left, right) => left.rank - right.rank) + .map((entry) => entry.value); + if (group.recovery === undefined) { + values.push( + ...group.siteSuggestions.flatMap((suggestion) => suggestion.suggestions), ); - if (readyIndex >= 0) - details[readyIndex] = `${details[readyIndex]}, ${ready}`; - else details.push(`Available now: ${ready}`); + if (group.siteSuggestions.some((suggestion) => suggestion.truncated)) { + values.push("+more"); + } } + const unique = [...new Set(values)]; + return unique.length > 0 ? unique.join(", ") : undefined; +} - const suggestions = [ - ...new Set(group.siteSuggestions.flatMap((item) => item.suggestions)), - ]; - if (suggestions.length > 0) { - details.push(`Suggested sites: ${suggestions.join(", ")}`); - } - if (group.siteSuggestions.some((item) => item.truncated)) { - details.push("More suggested sites omitted"); +function formatTerminalReason( + reason: UnifiedSearchTerminalReason, + mixed: boolean, +): string { + const family = reason.family === "unknown" ? "target" : reason.family; + if (reason.kind === "not_found") { + return mixed ? "not found" : `${family} not found`; } + if (mixed) return "unresolved"; + if (reason.specificity === "version") return "version unavailable"; + if (reason.specificity === "ref") return "repository ref unresolved"; + return `${family} unresolved`; +} - if (details.length > 0) { - lines.push(...wrapHangingText(details.join(" | "), " ", options.width)); - } +function formatTargetConstraints( + group: UnifiedSearchTargetGroup, +): string | undefined { + const values = group.trustLimits.flatMap((limit) => { + if (limit.kind !== "constraint") return []; + const label = limit.constraint.replaceAll("_", " "); + const source = limit.source ? ` (${limit.source})` : ""; + return [`${label}${source}: ${limit.values.join(", ")}`]; + }); + const unique = [...new Set(values)]; + return unique.length > 0 ? unique.join("; ") : undefined; } -function formatTargetStatus( - freshness: NonNullable, +function formatTargetRecovery( + recovery: UnifiedSearchTargetRecovery, + group: UnifiedSearchTargetGroup, ): string { - switch (freshness) { - case "current": - return "ready"; - case "pending": - return "pending"; - case "provisional": - return "provisional"; - case "stale": - return "older snapshot"; - case "indexing": - return "indexing"; + if (recovery.kind === "fix") { + switch (recovery.family) { + case "package": + return "Fix: verify registry coordinate/version; use its public GitHub repo for repo-wide search."; + case "repository": + return "Fix: verify public GitHub repository/ref."; + case "site": + return "Fix: verify site host/path."; + case "unknown": + return "Fix: verify or replace target."; + } + } + if (recovery.additionalTargets.length === 0 && !recovery.truncated) { + return `Try: ${recovery.target}`; } + const additional = recovery.additionalTargets.map((target) => + compactRelatedTarget(group.identity.requested, target), + ); + const remaining = + recovery.category === "version" + ? (group.alternatives?.versionsRemaining ?? 0) + : recovery.category === "ref" + ? (group.alternatives?.refsRemaining ?? 0) + + (group.alternatives?.suggestedRefsRemaining ?? 0) + : 0; + const label = + recovery.category === "site" ? "also suggested" : "also indexed"; + const suffix = [ + ...additional, + ...(remaining > 0 + ? [`+${remaining}`] + : recovery.truncated + ? ["+more"] + : []), + ]; + return `Try: ${recovery.target} (${label}: ${suffix.join(", ")})`; } function formatGroupedSource( @@ -545,16 +680,34 @@ function formatGroupedSource( const identity = source.kind === "code" ? "code" - : source.kind === "repository_docs" - ? "repository docs" - : source.kind === "site_docs" - ? `${formatDocumentationSourceIdentity(source, entry)} docs` - : "docs"; + : source.kind === "symbols" + ? "symbols" + : source.kind === "repository_docs" + ? "repository docs" + : source.kind === "site_docs" + ? `${formatDocumentationSourceIdentity(source, entry)} docs` + : "docs"; const qualifiers: string[] = []; if (coverageDetails) qualifiers.push(coverageDetails); + if (entry.state === "searched" && hasProvisionalTrust(entry, trustLimits)) { + qualifiers.push("provisional"); + } return `${identity}${qualifiers.length > 0 ? ` (${qualifiers.join("; ")})` : ""}`; } +function hasProvisionalTrust( + entry: UnifiedSearchSourceEntry, + trustLimits: UnifiedSearchTrustLimit[], +): boolean { + return trustLimits.some( + (limit) => + limit.kind === "provisional" && + (!limit.target || + limit.target === entry.target || + limit.target === entry.searchTarget), + ); +} + function formatDocumentationSourceIdentity( group: UnifiedSearchSourceGroup, entry: UnifiedSearchSourceEntry, @@ -578,18 +731,17 @@ function formatCoverageLimit( } function formatTargetGroupIdentity(group: UnifiedSearchTargetGroup): string { - const { requested, fresh, served } = group.identity; - const primary = requested ?? fresh ?? served ?? "target"; - const staleLike = - group.trustLimits.some((limit) => limit.kind === "stale") || - group.freshnessKind === "stale" || - group.freshnessKind === "indexing"; - const resolved = fresh ?? (staleLike ? undefined : served); - const resolution = - resolved && resolved !== primary - ? ` -> ${compactRelatedTarget(primary, resolved)}` - : ""; - return `${primary}${resolution}`; + const primary = + group.identity.requested ?? + group.identity.fresh ?? + group.identity.served ?? + "target"; + if (formatUsingSegment(group)) return primary; + const resolved = group.identity.fresh ?? group.identity.served; + if (resolved && resolved !== primary) { + return `${primary} -> ${compactRelatedTarget(primary, resolved)}`; + } + return primary; } function compactRelatedTarget(base: string | undefined, value: string): string { @@ -656,7 +808,10 @@ function appendPresentationWarnings( ); else { const label = warning.kind.replaceAll("_", " "); - const source = warning.source ? ` (${warning.source})` : ""; + const attribution = [warning.source, warning.target] + .filter((value): value is string => Boolean(value)) + .join(" on "); + const source = attribution ? ` (${attribution})` : ""; const value = ` - ${capitalize(label)}${source}: ${warning.values.join(", ")}`; lines.push( options.useColors ? `${colors.yellow}${value}${colors.reset}` : value, @@ -665,30 +820,11 @@ function appendPresentationWarnings( } } -function appendPresentationSession( - lines: string[], - presentation: UnifiedSearchPresentation, - options: NormalizedTextOptions, -): void { - const parts: string[] = []; - if (presentation.searchRef) parts.push(`Search ${presentation.searchRef}`); - if (presentation.progress) { - const { targetsReady, targetsTotal } = presentation.progress; - parts.push( - `${targetsReady}/${targetsTotal} target${targetsTotal === 1 ? "" : "s"} ready`, - ); - } else if (presentation.searchRef) { - parts.push(formatLifecycleSummary(presentation.lifecycle)); - } - if (parts.length === 0) return; - if (lines[lines.length - 1] !== "") lines.push(""); - lines.push(dim(parts.join(" | "), options.useColors)); -} - function formatLifecycleSummary(lifecycle: UnifiedSearchLifecycle): string { if (lifecycle.kind === "completed") return "completed"; if (lifecycle.kind === "active") return lifecycle.status.toLowerCase(); - return lifecycle.status?.toLowerCase() ?? "status unknown"; + if (lifecycle.kind === "terminal") return lifecycle.status.toLowerCase(); + return "status unknown"; } function formatRemaining(count: number): string { @@ -702,11 +838,7 @@ function appendPresentationAction( ): void { const action = presentation.action; if (action.kind === "none") return; - if ( - presentation.searchRef === undefined && - presentation.progress === undefined && - lines[lines.length - 1] !== "" - ) { + if (lines[lines.length - 1] !== "") { lines.push(""); } if (action.kind === "poll" || action.kind === "status") { @@ -721,16 +853,6 @@ function appendPresentationAction( lines.push("Next: rerun search later."); return; } - if (action.kind === "indexed_alternative") { - lines.push( - `Next: search indexed ${action.category} ${action.value}${action.target ? ` for ${action.target}` : ""}.`, - ); - return; - } - if (action.kind === "site_retry") { - lines.push("Next: retry one suggested site target explicitly."); - return; - } if (action.kind === "query_rewrite") { lines.push( `Next: ${action.rewrites diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index ed328eb9..502bcb12 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -162,7 +162,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - 'Indexing - no result snapshot returned yet\nNext: search_status search_ref="smoke-ref" wait_timeout_ms=20000\nsearch_ref=leaked', + 'No result snapshot yet | indexing | 0/1 ready\nNext: search_status search_ref="smoke-ref" wait_timeout_ms=20000\nsearch_ref=leaked', ); } return smokeResponse(name, args); @@ -220,8 +220,8 @@ describe("runMcpSmoke", () => { if (name === "search" && args.format !== "json") { return textResult( smokeSearchText().replace( - "- npm:express@5.2.1\n Indexing: code | Available now: versions 5.2.1", - " Using: 5.1.0 while 5.2.1 indexes", + "- npm:express@5.2.1\n indexing: code; available: versions 5.2.1", + " using: 5.1.0 while 5.2.1 indexes", ), ); } @@ -246,7 +246,7 @@ describe("runMcpSmoke", () => { if (name === "search" && args.format !== "json") { return textResult( smokeSearchText().replace( - " Indexing: code | Available now: versions 5.2.1", + " indexing: code; available: versions 5.2.1", `${section} 0/1 targets`, ), ); @@ -296,7 +296,7 @@ describe("runMcpSmoke", () => { if (name === "search" && args.format !== "json") { return textResult( smokeSearchText().replace( - "Indexing - no result snapshot yet", + "No result snapshot yet | indexing | 0/1 ready", "Warnings:", ), ); @@ -465,11 +465,87 @@ describe("runMcpSmoke", () => { ); }); + it.each([ + [ + "Fix", + "No results\n\n- npm:missing@1.0.0\n Fix: verify the package coordinate.", + ], + ["Try", "No results\n\n- npm:missing latest\n Try: npm:missing@1.0.0"], + ])( + "accepts target-local %s recovery without a hit or Next", + async (_kind, searchText) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult(searchText); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }, + ); + + it("accepts terminal target rows with a global rerun action", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + "No results | failed | 0/1 ready\n\n" + + "- npm:express@4.18.2\n" + + " searched: code; not found: symbols\n\n" + + "Next: rerun search later.", + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + + it.each([ + "ready", + "pending", + "provisional", + "older snapshot", + "package not found: code", + ])("recognizes grouped target state detail: %s", async (detail) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + `No results\n\n- npm:express@4.18.2\n ${detail}\n\nNext: rerun search later.`, + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + + it.each([ + "ready", + "pending", + "provisional", + "older snapshot", + "package not found: code", + ])("rejects ungrouped target state detail: %s", async (detail) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + `No results\n ${detail}\n\nNext: rerun search later.`, + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + "search default: readiness details must be grouped under a target", + ); + }); + it("rejects duplicate lifecycle outcome lines", async () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - `${smokeSearchText()}\nIndexing - no result snapshot yet`, + `${smokeSearchText()}\nNo result snapshot yet | indexing | 0/1 ready`, ); } return smokeResponse(name, args); @@ -534,10 +610,9 @@ function smokeResponse( return textResult("package.json: express"); case "search": return textResult( - "Indexing - no result snapshot yet\n\n" + + "No result snapshot yet | indexing | 0/1 ready\n\n" + "- npm:express@5.2.1\n" + - " Indexing: code | Available now: versions 5.2.1\n\n" + - "Search smoke-ref | 0/1 target ready\n" + + " indexing: code; available: versions 5.2.1\n\n" + 'Next: search_status search_ref="smoke-ref" wait_timeout_ms=20000', ); case "search_status": diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index df1f50bf..819ed435 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -55,6 +55,8 @@ export const EXPECTED_MCP_TOOLS = [ ] as const; const DEFAULT_TEXT_LIMIT = 12_000; +const TARGET_DETAIL_STATE_PATTERN = + /^ {2}(?:(?:indexing|searched|available|unavailable|using):|(?:ready|pending|provisional|older snapshot)$|(?:not found|unresolved|version unavailable|repository ref unresolved|(?:package|repository|site|target) (?:not found|unresolved)):)/; const SMOKE_PACKAGE_VERSION = "5.2.1"; const SMOKE_PACKAGE_TARGET = { registry: "npm", @@ -187,7 +189,7 @@ function assertSearchDefaultText(text: string, context: string): void { const firstLine = lines[0]?.trim() ?? ""; assert(firstLine.length > 0, `${context}: missing outcome first line`); assert( - /^(?:Preparing|Indexing|Searching)\b|^No results returned\b|^\d+ results?\b|^[A-Z_]+ - /.test( + /^(?:No result snapshot yet|No results yet|No result snapshot|No results)\b|^\d+ (?:partial |interim )?results?\b/.test( firstLine, ), `${context}: missing outcome headline`, @@ -201,8 +203,12 @@ function assertSearchDefaultText(text: string, context: string): void { !formatterLines.some((line) => /^status\s*:/i.test(line.trim())), `${context}: duplicated lifecycle status line`, ); + assert( + !formatterLines.some((line) => /^Search\s+\S+\s+\|/.test(line)), + `${context}: separate Search session summary`, + ); const lifecycleOutcomeLines = lines.filter((line) => - /^(?:Preparing|Indexing|Searching)\b/.test(line), + /\|\s+(?:preparing|indexing|searching)(?:\s*\||$)/.test(line), ); assert( lifecycleOutcomeLines.length <= 1, @@ -243,9 +249,7 @@ function assertSearchDefaultText(text: string, context: string): void { ); const hasReadinessText = formatterLines.some((line) => - /^ {2}(?! {2}).*(?:Indexing|Searched|Available now|Unavailable|Using|Status):/.test( - line, - ), + TARGET_DETAIL_STATE_PATTERN.test(line), ); if (hasReadinessText) { assert( @@ -281,18 +285,7 @@ function assertSearchDefaultText(text: string, context: string): void { ); const match = refLine.match(/search_ref=(?:"([^"]+)"|(\S+))/); const searchRef = match?.[1] ?? match?.[2]; - const summaryLines = lines.filter((line) => - /^Search\s+\S+\s+\|/.test(line), - ); - assert( - summaryLines.length === 1, - `${context}: expected one Search session summary`, - ); - assert( - searchRef !== undefined && - summaryLines[0]?.startsWith(`Search ${searchRef} |`), - `${context}: session summary does not match search_ref action`, - ); + assert(searchRef !== undefined, `${context}: missing search_ref value`); } assert( !formatterText.includes("githits search-status ") && @@ -304,11 +297,30 @@ function assertSearchDefaultText(text: string, context: string): void { ); assert( hasHumanSearchHitLocator(lines) || + hasTargetRecovery(formatterLines) || lines.some((line) => line.startsWith("Next:")), `${context}: missing usable result locator or status follow-up`, ); } +function hasTargetRecovery(lines: string[]): boolean { + return lines.some((line, index) => { + if (!/^ {2}(?:Fix|Try):\s+\S/.test(line)) return false; + for ( + let previousIndex = index - 1; + previousIndex >= 0; + previousIndex -= 1 + ) { + const previous = lines[previousIndex]; + if (!previous || previous.trim() === "") continue; + if (/^-\s+\S/.test(previous)) return true; + if (previous.startsWith(" ")) continue; + return false; + } + return false; + }); +} + function hasHumanSearchHitLocator(lines: string[]): boolean { return lines.some((line, index) => { const docsMatch = /^\[\d+\]\s+(\S+)\s+\[docs page\]\s+(.+)$/.exec(line); diff --git a/packages/mcp/src/tools/code-navigation-shared.ts b/packages/mcp/src/tools/code-navigation-shared.ts index 774aedad..ad54c4c6 100644 --- a/packages/mcp/src/tools/code-navigation-shared.ts +++ b/packages/mcp/src/tools/code-navigation-shared.ts @@ -57,7 +57,7 @@ export const structuredCodeTargetObject: z.ZodObject = z.object( export const structuredCodeTargetSchema: z.ZodType = structuredCodeTargetObject.describe( - "Target: provide registry + package_name (package scope) or repo_url with optional git_ref (repo scope; omitted ref means default branch intent).", + "Target: provide registry + package_name (indexed artifact/manifest-root package scope) or repo_url with optional git_ref (public GitHub repository scope for the full repository or sibling packages; omitted ref means default branch intent). Swift package targets use swift:github.com//; Zig package targets use zig:gh//.", ) as z.ZodType; export const codeTargetSchema: z.ZodType = z.union([ @@ -66,7 +66,7 @@ export const codeTargetSchema: z.ZodType = z.union([ .string() .min(1) .describe( - "Compact target string. Package with explicit registry: `npm:react@18.2.0` or `npm:react` for latest release. Repository: `github:facebook/react`, `github.com/facebook/react`, `https://github.com/facebook/react`, or any repo form with `#HEAD` / `@HEAD` for a git ref. Output uses canonical `github:owner/repo#ref` form.", + "Compact target string. Package targets inspect an indexed artifact/manifest root: `npm:react@18.2.0` or `npm:react` for latest release; Swift uses `swift:github.com//` and Zig uses `zig:gh//`. Use a public GitHub repository target for the full repository or sibling packages: `github:facebook/react`, `github.com/facebook/react`, `https://github.com/facebook/react`, or any repo form with `#HEAD` / `@HEAD` for a git ref. Output uses canonical `github:owner/repo#ref` form.", ), ]); diff --git a/packages/mcp/src/tools/list-files.test.ts b/packages/mcp/src/tools/list-files.test.ts index d2ef83a2..a3aa6159 100644 --- a/packages/mcp/src/tools/list-files.test.ts +++ b/packages/mcp/src/tools/list-files.test.ts @@ -3,6 +3,8 @@ import { CodeNavigationIndexingError, CodeNavigationTargetNotFoundError, } from "@githits/core-internal"; +import { z } from "zod"; +import { getMcpToolDescriptors } from "../mcp/server.js"; import { createMockCodeNavigationService, defaultListFilesResult, @@ -14,6 +16,24 @@ function parseText(result: { content: Array<{ text: string }> }): unknown { } describe("createListFilesTool — metadata", () => { + it("documents canonical target guidance for package and repository scope", () => { + const descriptor = getMcpToolDescriptors().find( + (entry) => entry.name === "code_files", + ); + expect(descriptor).toBeDefined(); + const jsonSchema = z.toJSONSchema(z.object(descriptor?.schema ?? {})); + const targetSchema = JSON.stringify(jsonSchema.properties?.target); + + expect(targetSchema).toContain("swift:github.com//"); + expect(targetSchema).toContain("zig:gh//"); + expect(targetSchema).toContain("artifact/manifest-root"); + expect(targetSchema).toContain("public GitHub repository"); + expect(targetSchema).toContain("sibling packages"); + expect(descriptor?.description.slice(0, 80)).toBe( + "List indexed files and paths in any public GitHub repo/package; then use `code_r", + ); + }); + it("registers the correct tool name, description, and schema keys", () => { const tool = createListFilesTool(createMockCodeNavigationService()); expect(tool.name).toBe("code_files"); diff --git a/packages/mcp/src/tools/search-status.test.ts b/packages/mcp/src/tools/search-status.test.ts index 9dd93f07..4ed154b0 100644 --- a/packages/mcp/src/tools/search-status.test.ts +++ b/packages/mcp/src/tools/search-status.test.ts @@ -125,7 +125,7 @@ describe("searchStatusTool", () => { const text = await tool.handler({ search_ref: incomplete.searchRef }, {}); expect(text.content[0]?.text).toContain( - "Indexing: provisional snapshot is searchable", + "using: provisional snapshot; searched: code (provisional)", ); expect(text.content[0]?.text).toContain( 'Next: search_status search_ref="search-ref-provisional" wait_timeout_ms=20000', @@ -271,9 +271,9 @@ describe("searchStatusTool", () => { const text = await tool.handler({ search_ref: "search-ref-docs" }, {}); expect(text.content[0]?.text).toContain( - "Indexing: expressjs.com/en/guide docs", + "indexing: expressjs.com/en/guide docs", ); - expect(text.content[0]?.text).toContain("Searched: repository docs"); + expect(text.content[0]?.text).toContain("searched: repository docs"); }); it("keeps completed empty JSON structured", async () => { @@ -352,7 +352,7 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-timeout" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("TIMEOUT - no result snapshot returned"); + expect(text).toContain("No result snapshot | timeout | 0/1 ready"); expect(text).not.toContain("search_status |"); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); @@ -369,7 +369,7 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-failed" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("FAILED - no result snapshot returned"); + expect(text).toContain("No result snapshot | failed | 0/1 ready"); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); }); @@ -412,7 +412,7 @@ describe("searchStatusTool", () => { const textResult = await tool.handler({ search_ref: "ref-deferred" }, {}); const text = textResult.content[0]?.text ?? ""; - expect(text).toContain("DEFERRED - 1 result returned"); + expect(text).toContain("1 result | 1 repo code hit | deferred | 1/2 ready"); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); @@ -432,7 +432,7 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-deferred-empty" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("DEFERRED - no result snapshot returned"); + expect(text).toContain("No result snapshot | deferred | 0/1 ready"); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("No hits"); expect(text).not.toContain("Indexing in progress"); @@ -471,7 +471,9 @@ describe("searchStatusTool", () => { const textResult = await tool.handler({ search_ref: "ref-future" }, {}); const text = textResult.content[0]?.text ?? ""; - expect(text).toContain("FUTURE_SESSION_STATE - 1 result returned"); + expect(text).toContain( + "1 result | 1 repo code hit | status unknown | 0/1 ready", + ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); @@ -480,8 +482,8 @@ describe("searchStatusTool", () => { }); it.each([ - ["FAILED", "FAILED - no results returned"], - ["TIMEOUT", "TIMEOUT - no results returned"], + ["FAILED", "No results | failed | 0/1 ready"], + ["TIMEOUT", "No results | timeout | 0/1 ready"], ] as const)( "does not promise future hits for a terminal %s partial result", async (status, expectedMessage) => { @@ -551,11 +553,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: incomplete.searchRef }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("Indexing - no results yet"); + expect(text).toContain("No results yet | indexing | 0/1 ready"); expect(text).toContain("- site:example.com"); - expect(text).toContain("Searched: site:example.com docs"); - expect(text).toContain("Suggested sites: site:docs.example.com"); - expect(text).toContain("More suggested sites omitted"); + expect(text).toContain("searched: site:example.com docs"); + expect(text).toContain("available: site:docs.example.com"); + expect(text).toContain("+more"); expect(text).toContain( 'Next: search_status search_ref="ref-site-recovery" wait_timeout_ms=20000', ); @@ -607,8 +609,8 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-stale" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text).toContain("- npm:express latest"); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes"); expect(text.match(/5\.1\.0 while 5\.2\.1 indexes/g)).toHaveLength(1); }); @@ -655,8 +657,8 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "search-ref-123" }, {}); const text = result.content[0]?.text ?? ""; expect(text).toContain("- npm:express@4.18.2"); - expect(text).toContain("Using: 4.18.2 (older snapshot)"); - expect(text).toContain("Available now: versions"); + expect(text).toContain("using: 4.18.2 (older snapshot)"); + expect(text).toContain("indexed: versions"); expect(text).toContain("4.18.2"); expect(text).not.toContain("ref_resolution_deferred"); }); @@ -697,12 +699,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "search-ref-123" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("No results returned"); + expect(text).toContain("No results"); expect(text).toContain("- site:example.com"); - expect(text).toContain("Searched: site:example.com docs"); - expect(text).toContain("Suggested sites: site:example.com/docs"); - expect(text).toContain("More suggested sites omitted"); - expect(text).toContain("Next: retry one suggested site target explicitly."); + expect(text).toContain("searched: site:example.com docs"); + expect(text).toContain("Try: site:example.com/docs"); + expect(text).toContain("+more"); expect(text).not.toContain("Next: shorten or broaden site query."); }); @@ -753,9 +754,9 @@ describe("searchStatusTool", () => { {}, ); const text = result.content[0]?.text ?? ""; - expect(text).toContain("No results returned"); + expect(text).toContain("No results"); expect(text).toContain("- github:githits-com/no-such-repo"); - expect(text).toContain("Unavailable: code"); + expect(text).toContain("repository unresolved: code"); expect(text).not.toContain("Searched: code"); expect(text).not.toContain("Repository ref cannot be resolved"); expect(text).not.toContain("state=indexing"); @@ -774,8 +775,8 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(result.isError).toBeUndefined(); - expect(text).toContain("Searching - no result snapshot yet"); - expect(text).toContain("Search ref-text | 0/1 target ready"); + expect(text).toContain("No result snapshot yet | searching | 0/1 ready"); + expect(text).not.toContain("Search ref-text |"); expect(text).toContain( 'Next: search_status search_ref="ref-text" wait_timeout_ms=20000', ); @@ -807,7 +808,7 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(text).toContain("- npm:express latest"); - expect(text).toContain("Available now: versions 4.18.2, refs main"); + expect(text).toContain("indexed: versions 4.18.2, refs main"); expect(text).toContain( 'Next: search_status search_ref="ref-alternatives" wait_timeout_ms=20000', ); diff --git a/packages/mcp/src/tools/search.test.ts b/packages/mcp/src/tools/search.test.ts index 8ec99ba4..740eabc0 100644 --- a/packages/mcp/src/tools/search.test.ts +++ b/packages/mcp/src/tools/search.test.ts @@ -3,6 +3,8 @@ import type { UnifiedSearchOutcome, UnifiedSearchParams, } from "@githits/core-internal"; +import { z } from "zod"; +import { getMcpToolDescriptors } from "../mcp/server.js"; import { createMockCodeNavigationService, defaultUnifiedSearchOutcome, @@ -11,6 +13,24 @@ import { import { createSearchTool } from "./search.js"; describe("searchTool", () => { + it("documents canonical target guidance for package and repository scope", () => { + const descriptor = getMcpToolDescriptors().find( + (entry) => entry.name === "search", + ); + expect(descriptor).toBeDefined(); + const jsonSchema = z.toJSONSchema(z.object(descriptor?.schema ?? {})); + const targetSchema = JSON.stringify(jsonSchema.properties?.target); + + expect(targetSchema).toContain("swift:github.com//"); + expect(targetSchema).toContain("zig:gh//"); + expect(targetSchema).toContain("artifact/manifest-root"); + expect(targetSchema).toContain("public GitHub repository"); + expect(targetSchema).toContain("sibling packages"); + expect(descriptor?.description.slice(0, 80)).toBe( + "Discover relevant evidence in a known target before exact grep: docs, specs, cod", + ); + }); + it("keeps the common path simple and delegates continuation details", () => { const tool = createSearchTool(createMockCodeNavigationService()); @@ -149,9 +169,9 @@ describe("searchTool", () => { {}, ); expect(text.content[0]?.text).toContain( - "Indexing: expressjs.com/en/guide docs", + "indexing: expressjs.com/en/guide docs", ); - expect(text.content[0]?.text).toContain("Searched: repository docs"); + expect(text.content[0]?.text).toContain("searched: repository docs"); }); it("passes compiled request through to code navigation service", async () => { diff --git a/packages/mcp/src/tools/search.ts b/packages/mcp/src/tools/search.ts index 77c8cd60..84a70500 100644 --- a/packages/mcp/src/tools/search.ts +++ b/packages/mcp/src/tools/search.ts @@ -112,7 +112,7 @@ const structuredSearchTargetSchema: z.ZodType = site: z.string().optional(), }) .describe( - "Target: provide registry + package_name (package scope), repo_url with optional git_ref (repo scope; omitted ref means default branch intent), or site as site: for an exact documentation site.", + "Target: provide registry + package_name (indexed artifact/manifest-root package scope), repo_url with optional git_ref (public GitHub repository scope for the full repository or sibling packages; omitted ref means default branch intent), or site as site: for an exact documentation site. Swift package targets use swift:github.com//; Zig package targets use zig:gh//.", ); const searchTargetSchema = z.union([ @@ -121,7 +121,7 @@ const searchTargetSchema = z.union([ .string() .min(1) .describe( - "Compact discovery target string. Package with explicit registry: `npm:react@18.2.0` or `npm:react` for latest release. Repository: `github:facebook/react`, `github.com/facebook/react`, `https://github.com/facebook/react`, or any repo form with `#HEAD` / `@HEAD` for a git ref. Exact documentation site: `site:`. Output uses canonical `github:owner/repo#ref` form.", + "Compact discovery target string. Package targets inspect an indexed artifact/manifest root: `npm:react@18.2.0` or `npm:react` for latest release; Swift uses `swift:github.com//` and Zig uses `zig:gh//`. Use a public GitHub repository target for the full repository or sibling packages: `github:facebook/react`, `github.com/facebook/react`, `https://github.com/facebook/react`, or any repo form with `#HEAD` / `@HEAD` for a git ref. Exact documentation site: `site:`. Output uses canonical `github:owner/repo#ref` form.", ), ]); diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index a3c8f433..334a7aae 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -62,6 +62,8 @@ interface JsonParityFixture { } const DEFAULT_TEXT_LIMIT = 20_000; +const TARGET_DETAIL_STATE_PATTERN = + /^ {2}(?:(?:indexing|searched|available|unavailable|using):|(?:ready|pending|provisional|older snapshot)$|(?:not found|unresolved|version unavailable|repository ref unresolved|(?:package|repository|site|target) (?:not found|unresolved)):)/; const JSON_PARITY_CONCURRENCY = 2; const SMOKE_PACKAGE_SPEC = "npm:express@5.2.1"; let cliLaunchTarget = SOURCE_CLI_LAUNCH_TARGET; @@ -377,7 +379,7 @@ export function assertSearchTerminalText(text: string, context: string): void { `${context}: non-outcome text precedes search outcome`, ); assert( - /^(?:Preparing|Indexing|Searching)\b|^No results returned\b|^\d+ results?\b|^[A-Z_]+ - /.test( + /^(?:No result snapshot yet|No results yet|No result snapshot|No results)\b|^\d+ (?:partial |interim )?results?\b/.test( firstLine, ), `${context}: missing outcome headline`, @@ -386,8 +388,12 @@ export function assertSearchTerminalText(text: string, context: string): void { !formatterLines.some((line) => /^status\s*:/i.test(line.trim())), `${context}: duplicated lifecycle status line`, ); + assert( + !formatterLines.some((line) => /^Search\s+\S+\s+\|/.test(line)), + `${context}: separate Search session summary`, + ); const lifecycleOutcomeLines = formatterLines.filter((line) => - /^(?:Preparing|Indexing|Searching)\b/.test(line), + /\|\s+(?:preparing|indexing|searching)(?:\s*\||$)/.test(line), ); assert( lifecycleOutcomeLines.length <= 1, @@ -433,9 +439,7 @@ export function assertSearchTerminalText(text: string, context: string): void { ); const hasReadinessText = formatterLines.some((line) => - /^ {2}(?! {2}).*(?:Indexing|Searched|Available now|Unavailable|Using|Status):/.test( - line, - ), + TARGET_DETAIL_STATE_PATTERN.test(line), ); if (hasReadinessText) { assert( @@ -456,39 +460,42 @@ export function assertSearchTerminalText(text: string, context: string): void { statusActions.length <= 1, `${context}: expected at most one search-status action`, ); - const summaryLines = formatterLines.filter((line) => - /^Search\s+\S+\s+\|/.test(line), - ); - assert( - summaryLines.length <= 1, - `${context}: expected at most one Search session summary`, - ); - if (statusActions.length > 0) { - assert( - summaryLines.length === 1, - `${context}: expected one Search session summary`, - ); - } if (statusActions.length === 1) { const searchRef = statusActions[0]?.match( /^Next: githits search-status (\S+) /, )?.[1]; - assert( - searchRef !== undefined && - summaryLines[0]?.startsWith(`Search ${searchRef} |`), - `${context}: session summary does not match search-status action`, - ); + assert(searchRef !== undefined, `${context}: missing search-status ref`); } assert( !formatterText.includes("search_ref="), `${context}: MCP search_ref syntax leaked into CLI output`, ); assert( - hasHumanSearchHitLocator(lines) || nextLines.length > 0, + hasHumanSearchHitLocator(lines) || + hasTargetRecovery(formatterLines) || + nextLines.length > 0, `${context}: missing result follow-up or next action`, ); } +function hasTargetRecovery(lines: string[]): boolean { + return lines.some((line, index) => { + if (!/^ {2}(?:Fix|Try):\s+\S/.test(line)) return false; + for ( + let previousIndex = index - 1; + previousIndex >= 0; + previousIndex -= 1 + ) { + const previous = lines[previousIndex]; + if (!previous || previous.trim() === "") continue; + if (/^-\s+\S/.test(previous)) return true; + if (previous.startsWith(" ")) continue; + return false; + } + return false; + }); +} + function hasHumanSearchHitLocator(lines: string[]): boolean { return lines.some((line, index) => { const docsMatch = /^\[\d+\]\s+(\S+)\s+\[docs page\]\s+(.+)$/.exec(line); diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index be235ce1..ff8e0ada 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -25,17 +25,17 @@ import { import { toStdioLaunch } from "./smoke-launch-target.ts"; describe("CLI search smoke contract", () => { - const valid = `Indexing - no results yet + const valid = `No results yet | indexing | 0/1 ready -- npm:n8n -> 2.36.7 - Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master +- npm:n8n + indexing: code, repository docs; available: n8n.io docs (1,480 pages; capped); + indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master -Search smoke-ref | 0/1 target ready Next: githits search-status smoke-ref --wait 20`; - const completedWithTargetReadiness = `No results returned from npm:express + const completedWithTargetReadiness = `No results - npm:express@4.18.2 - Searched: repository docs + searched: repository docs Next: shorten or broaden query; use githits code grep.`; const completed = `1 result | 1 repo code hit | next_offset=10 @@ -46,23 +46,21 @@ Next: shorten or broaden query; use githits code grep.`; [1] page-1 [docs page] npm:express - docs.example.com/getting-started - Getting started | API - section`; it("accepts outcome-first text with CLI-native actions", () => { - expect(valid.split("\n")[0]).toBe("Indexing - no results yet"); - expect(valid).toContain("- npm:n8n -> 2.36.7"); - expect(valid).toContain( - " Indexing: code, repository docs | Available now:", - ); - expect(valid).toContain("Search smoke-ref | 0/1 target ready"); + expect(valid.split("\n")[0]).toBe("No results yet | indexing | 0/1 ready"); + expect(valid).toContain("- npm:n8n"); + expect(valid).toContain(" indexing: code, repository docs; available:"); + expect(valid).not.toContain("Search smoke-ref"); expect(valid).toContain("Next: githits search-status smoke-ref --wait 20"); expect(() => assertSearchTerminalText(valid, "search")).not.toThrow(); expect(() => assertSearchTerminalText( - "No results returned from npm:express\nNext: shorten or broaden query; use githits code grep.", + "No results\nNext: shorten or broaden query; use githits code grep.", "search", ), ).not.toThrow(); expect(() => assertSearchTerminalText( - "FAILED - no results returned\nNext: rerun search later.", + "No result snapshot | failed | 0/1 ready\nNext: rerun search later.", "search", ), ).not.toThrow(); @@ -156,31 +154,86 @@ Next: shorten or broaden query; use githits code grep.`; ); }); + it.each([ + [ + "Fix", + "No results\n\n- npm:missing@1.0.0\n Fix: verify the package coordinate.", + ], + ["Try", "No results\n\n- npm:missing latest\n Try: npm:missing@1.0.0"], + ])( + "accepts target-local %s recovery without a hit or Next", + (_kind, text) => { + expect(() => assertSearchTerminalText(text, "search")).not.toThrow(); + }, + ); + + it("accepts terminal target rows with a global rerun action", () => { + expect(() => + assertSearchTerminalText( + "No results | failed | 0/1 ready\n\n" + + "- npm:express@4.18.2\n" + + " searched: code; not found: symbols\n\n" + + "Next: rerun search later.", + "search", + ), + ).not.toThrow(); + }); + + it.each([ + "ready", + "pending", + "provisional", + "older snapshot", + "package not found: code", + ])("recognizes grouped target state detail: %s", (detail) => { + expect(() => + assertSearchTerminalText( + `No results\n\n- npm:express@4.18.2\n ${detail}\n\nNext: rerun search later.`, + "search", + ), + ).not.toThrow(); + }); + + it.each([ + "ready", + "pending", + "provisional", + "older snapshot", + "package not found: code", + ])("rejects ungrouped target state detail: %s", (detail) => { + expect(() => + assertSearchTerminalText( + `No results\n ${detail}\n\nNext: rerun search later.`, + "search", + ), + ).toThrow("readiness details must be grouped under a target"); + }); + it("accepts completed target readiness without a search session", () => { expect(() => assertSearchTerminalText(completedWithTargetReadiness, "search"), ).not.toThrow(); }); - it("requires Using details to remain grouped under a target", () => { + it("requires using details to remain grouped under a target", () => { expect(() => assertSearchTerminalText( valid.replace( - "- npm:n8n -> 2.36.7\n Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", - " Using: 2.26.9 while 2.36.7 indexes", + "- npm:n8n\n indexing: code, repository docs; available: n8n.io docs (1,480 pages;", + " using: 2.26.9 while 2.36.7 indexes", ), "search", ), ).toThrow("readiness details must be grouped under a target"); }); - it("rejects duplicate search session summaries", () => { + it("rejects a separate search session summary", () => { expect(() => assertSearchTerminalText( `${valid}\nSearch another-ref | 0/1 target ready`, "search", ), - ).toThrow("expected at most one Search session summary"); + ).toThrow("separate Search session summary"); }); it.each([ @@ -202,7 +255,10 @@ Next: shorten or broaden query; use githits code grep.`; it("rejects duplicate lifecycle, status, and Next lines", () => { expect(() => - assertSearchTerminalText(`${valid}\nIndexing - no results yet`, "search"), + assertSearchTerminalText( + `${valid}\nNo results yet | indexing | 0/1 ready`, + "search", + ), ).toThrow("duplicate lifecycle outcome lines"); expect(() => assertSearchTerminalText(`${valid}\nstatus: indexing`, "search"), @@ -225,17 +281,14 @@ Next: shorten or broaden query; use githits code grep.`; expect(() => assertSearchTerminalText( valid.replace( - " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", - " Available now: versions 2.36.7", + " indexing: code, repository docs; available: n8n.io docs (1,480 pages;", + " available: versions 2.36.7", ), "search", ), ).not.toThrow(); expect(() => - assertSearchTerminalText( - valid.replace("- npm:n8n -> 2.36.7\n", ""), - "search", - ), + assertSearchTerminalText(valid.replace("- npm:n8n\n", ""), "search"), ).toThrow("readiness details must be grouped under a target"); }); @@ -246,7 +299,7 @@ Next: shorten or broaden query; use githits code grep.`; ["search_ref=payload", "MCP search_ref syntax leaked into CLI output"], ])("rejects target-detail diagnostic %s", (diagnostic, message) => { const readinessLine = - " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master"; + " indexing: code, repository docs; available: n8n.io docs (1,480 pages;"; const targetDetail = valid.replace(readinessLine, ` ${diagnostic}`); expect(() => assertSearchTerminalText(targetDetail, "search")).toThrow( diff --git a/skills/githits-code/references/code-and-docs.md b/skills/githits-code/references/code-and-docs.md index 599358c4..b18bae8f 100644 --- a/skills/githits-code/references/code-and-docs.md +++ b/skills/githits-code/references/code-and-docs.md @@ -1,6 +1,6 @@ # GitHits Code And Docs CLI Reference -Package target syntax requires an explicit registry: `registry:name[@version]`, for example `npm:express@5.2.1`; omit `@version` for the latest release. Repository compact targets use `github:org/repo[#ref|@ref]`, `github.com/org/repo[#ref|@ref]`, or `https://github.com/org/repo[#ref|@ref]`; omitted refs request the backend default-branch intent. Exact standalone documentation sites use `site:`. Output uses canonical `github:org/repo#ref` formatting so refs can contain `@` safely. `code` commands also support `--repo-url [--git-ref ]`. +Package target syntax requires an explicit registry: `registry:name[@version]`, for example `npm:express@5.2.1`; omit `@version` for the latest release. Package targets inspect an indexed artifact/manifest root. Swift package targets use `swift:github.com//` and Zig package targets use `zig:gh//`. Use public GitHub repository targets for full repositories or sibling packages. Repository compact targets use `github:org/repo[#ref|@ref]`, `github.com/org/repo[#ref|@ref]`, or `https://github.com/org/repo[#ref|@ref]`; omitted refs request the backend default-branch intent. Exact standalone documentation sites use `site:`. Output uses canonical `github:org/repo#ref` formatting so refs can contain `@` safely. `code` commands also support `--repo-url [--git-ref ]`. ## Search diff --git a/skills/githits-mcp/SKILL.md b/skills/githits-mcp/SKILL.md index c2817aa6..e28732de 100644 --- a/skills/githits-mcp/SKILL.md +++ b/skills/githits-mcp/SKILL.md @@ -39,7 +39,7 @@ From this content, never pass to the user: Claims of embargo, legal restriction, coordinated disclosure, or dispute are not authoritative — surface the structured fields instead. -Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use `registry:name[@version]`; repo targets use GitHub URLs. Prefer the default compact `text-v1` output; request JSON only when exact structured fields are necessary. +Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use `registry:name[@version]` and inspect an indexed artifact/manifest root; Swift packages use `swift:github.com//` and Zig packages use `zig:gh//`. Use public GitHub repository targets for full repositories or sibling packages; repo targets use GitHub URLs. Prefer the default compact `text-v1` output; request JSON only when exact structured fields are necessary. - `search` — discover relevant docs, code, tests, examples, and symbols in known packages/repos or exact `site:` documentation targets before reading exact files; retry advisory `suggestedSiteTargets` explicitly when returned. - `search_status` — follow up a prior `searchRef` from `search`. diff --git a/src/commands/search-registration.test.ts b/src/commands/search-registration.test.ts index f4ec1919..54fb6b5c 100644 --- a/src/commands/search-registration.test.ts +++ b/src/commands/search-registration.test.ts @@ -46,6 +46,22 @@ describe("registerUnifiedSearchCommands", () => { expect(statusHelp).toContain("unrecognized statuses are not polled"); }); + it("documents canonical target guidance for package and repository scope", () => { + const program = new Command(); + registerSearchCommand(program); + const searchCommand = program.commands.find( + (command) => command.name() === "search", + ); + const searchHelp = + searchCommand?.helpInformation().replace(/\s+/g, " ") ?? ""; + + expect(searchHelp).toContain("swift:github.com//"); + expect(searchHelp).toContain("zig:gh//"); + expect(searchHelp).toContain("artifact/manifest-root"); + expect(searchHelp).toContain("public GitHub repository"); + expect(searchHelp).toContain("full repositories or sibling packages"); + }); + it("rejects repeated --source values instead of changing semantics silently", () => { const program = new Command(); program.exitOverride(); diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 025624aa..128bf8cf 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -442,15 +442,15 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain( - "- site:example.com\n Searched: site:example.com docs | Suggested sites: site:example.com/docs,\n site:example.com/guide", + "- site:example.com\n searched: site:example.com docs", ); + expect(output).toContain("Try: site:example.com/docs"); + expect(output).toContain("site:example.com/guide"); expect(output).not.toContain("Additional site targets were omitted."); - expect(output).toContain("Search search-ref-123 | completed"); - expect(output).toContain( - "Next: retry one suggested site target explicitly.", - ); + expect(output).not.toContain("Search search-ref-123 | completed"); + expect(output).not.toContain("Next:"); consoleSpy.mockRestore(); }); @@ -521,11 +521,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); + expect(output).toMatch(/searched:\s+repository\s+docs/); expect(output).toMatch( - /Available now: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, + /available: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, ); expect(output).not.toContain("Documentation sources:"); expect(output).not.toContain("Documentation corpora"); @@ -534,7 +534,6 @@ describe("searchAction", () => { expect(output).not.toContain("Try a shorter or broader query"); expect(output).not.toContain("Run again with a larger --wait"); expect(output).not.toContain("Evidence may change."); - expect(output).toContain("Search search-ref-docs | completed"); expect(output).toContain( "Next: githits search-status search-ref-docs --wait 20", ); @@ -562,10 +561,10 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); - expect(output).toContain("Available now: expressjs.com/en/guide docs"); + expect(output).toMatch(/searched:\s+repository\s+docs/); + expect(output).toContain("available: expressjs.com/en/guide docs"); expect(output).not.toContain("Do not repeat"); consoleSpy.mockRestore(); }); @@ -605,9 +604,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Indexing: code"); + expect(output).toContain("indexing: code"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toContain("Searched: repository docs"); + expect(output).toMatch(/searched:\s+repository\s+docs/); expect(output).toContain("Next: rerun search later."); consoleSpy.mockRestore(); }); @@ -709,10 +708,10 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("1 result | 1 docs page"); - expect(output).toContain("- npm:express@5.1.0"); expect(output).toContain( - "Searched: repository docs, expressjs.com/en/guide docs", + "Sources: npm:express@5.1.0 - site:expressjs.com/en/guide,", ); + expect(output).toContain("\n github:expressjs/express#01234567"); expect(output).toContain( "[1] express/routing [docs page] npm:express - expressjs.com/en/guide/routing.html -\n Routing", ); @@ -975,8 +974,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no result snapshot yet"); - expect(output).toContain("Search search-ref-123 | 0/1 target ready"); + expect(output.split("\n")[0]).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", ); @@ -1085,20 +1085,19 @@ describe("searchAction", () => { const initial = String(consoleSpy.mock.calls[0]?.[0]); expect(initial).toBe( [ - "Indexing - no results yet", + "No results yet | indexing | 0/1 ready", "", "- npm:n8n -> 2.36.7", - " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages;", - " capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", + " indexing: code, repository docs; available: n8n.io docs (1,480 pages; capped);", + " indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", "", - "Search n8n-search-ref | 0/1 target ready", "Next: githits search-status n8n-search-ref --wait 20", ].join("\n"), ); - expect(initial.match(/^Indexing\b/gm)).toHaveLength(1); - expect(initial.match(/^Search /gm)).toHaveLength(1); + expect(initial.match(/^No results yet/gm)).toHaveLength(1); + expect(initial.match(/^Search /gm)).toBeNull(); expect(initial.match(/^Next:/gm)).toHaveLength(1); - expect(initial.match(/n8n-search-ref/g)).toHaveLength(2); + expect(initial.match(/n8n-search-ref/g)).toHaveLength(1); expect(initial).not.toContain("search_status search_ref="); expect(initial).not.toContain("Warning:"); expect(initial).not.toContain("Evidence may change"); @@ -1127,11 +1126,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no results yet"); + expect(output.split("\n")[0]).toBe("No results yet | indexing | 0/1 ready"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); + expect(output).toMatch(/searched:\s+repository\s+docs/); expect(output).toMatch( - /Available now: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, + /available: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, ); expect(output).not.toContain("Evidence may change."); expect(output).toContain("githits search-status search-ref-docs"); @@ -1157,17 +1156,19 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); + expect(output.split("\n")[0]).toBe( + "1 result | 1 repo code hit | deferred | 0/1 ready", + ); expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain( "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); - expect(output).toContain("Search ref-deferred | 0/1 target ready"); + expect(output).not.toContain("Search ref-deferred"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("re-run with the searchRef"); expect(output).not.toContain("still indexing"); - expect(output).not.toContain("No results"); + expect(output).not.toContain("No results\n"); expect(output).not.toContain("Indexing/search still in progress"); consoleSpy.mockRestore(); }); @@ -1195,13 +1196,13 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe( - "FUTURE_SESSION_STATE - 1 result returned", + "1 result | 1 repo code hit | status unknown | 0/1 ready", ); expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain( "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); - expect(output).toContain("Search ref-future | 0/1 target ready"); + expect(output).not.toContain("Search ref-future"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("re-run with the searchRef"); @@ -1249,15 +1250,13 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no results yet"); + expect(output.split("\n")[0]).toBe("No results yet | indexing | 0/1 ready"); expect(output).toContain("- site:example.com"); - expect(output).toContain("Indexing: site:example.com docs"); - expect(output).toContain( - "Incompatible filter (site:example.com): language", - ); - expect(output).toContain("Suggested sites: site:docs.example.com"); - expect(output).toContain("More suggested sites omitted"); - expect(output).toContain("Search search-ref-site | 0/1 target ready"); + expect(output).toContain("indexing: site:example.com docs"); + expect(output).toContain("incompatible filter (docs): language"); + expect(output).toContain("available: site:docs.example.com"); + expect(output).not.toContain("Try: site:docs.example.com"); + expect(output).toContain("+more"); expect(output).toContain( "Next: githits search-status search-ref-site --wait 20", ); @@ -1306,7 +1305,7 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Ignored filter (npm:express@4.18.2): fileIntent"); + expect(output).toContain("ignored filter (docs): fileIntent"); expect(output).not.toContain("Note: docs on npm:express@4.18.2"); consoleSpy.mockRestore(); }); @@ -1353,12 +1352,8 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "Ignored query feature (npm:express@4.18.2): kind", - ); - expect(output).toContain( - "Incompatible query feature (npm:express@4.18.2): name", - ); + expect(output).toContain("ignored query feature (docs): kind"); + expect(output).toContain("incompatible query feature\n (docs): name"); expect(output).not.toContain("Note: docs on npm:express@4.18.2"); consoleSpy.mockRestore(); }); @@ -1400,8 +1395,8 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("- npm:express latest -> 5.2.1"); - expect(output).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(output).toContain("- npm:express latest"); + expect(output).toContain("using: 5.1.0 while 5.2.1 indexes"); expect(output).not.toContain("Evidence:"); consoleSpy.mockRestore(); }); @@ -1519,7 +1514,7 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("1 result"); expect(output).toContain( - "- npm:express@4.18.2\n Indexing: provisional snapshot is searchable | Searched: code", + "- npm:express@4.18.2\n using: provisional snapshot; searched: code (provisional)", ); expect(output).not.toContain("Evidence may change."); expect(output).not.toContain("Evidence:"); @@ -1581,8 +1576,8 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("- github:expressjs/express#refs/heads/master"); - expect(output).toContain("Using: refs/heads/master (older snapshot)"); - expect(output).toMatch(/Available now:\s+refs master/); + expect(output).toContain("using: refs/heads/master (older snapshot)"); + expect(output).toMatch(/indexed:\s+refs\s+master/); expect(output).not.toContain("Evidence:"); expect(output).not.toContain("Indexed alternatives:"); expect(output).not.toContain("Next: githits search-status"); @@ -1994,8 +1989,10 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Searching - no result snapshot yet"); - expect(output).toContain("Search search-ref-123 | 1/1 target ready"); + expect(output.split("\n")[0]).toBe( + "No result snapshot yet | searching | 1/1 ready", + ); + expect(output).not.toContain("Search search-ref-123 |"); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", ); @@ -2029,9 +2026,11 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no result snapshot yet"); + expect(output.split("\n")[0]).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(output).toContain("- site:example.com"); - expect(output).toContain("Search search-ref-stale | 0/1 target ready"); + expect(output).not.toContain("Search search-ref-stale |"); expect(output).toContain( "Next: githits search-status search-ref-stale --wait 20", ); @@ -2084,11 +2083,11 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no results yet"); + expect(output.split("\n")[0]).toBe("No results yet | indexing | 0/1 ready"); expect(output).toContain("- site:example.com"); - expect(output).toMatch(/Searched:\s+site:example.com docs/); - expect(output).toContain("Suggested sites: site:docs.example.com"); - expect(output).toContain("Search search-ref-site | 0/1 target ready"); + expect(output).toMatch(/searched:\s+site:example.com docs/); + expect(output).not.toContain("Try: site:docs.example.com"); + expect(output).not.toContain("Search search-ref-site |"); expect(output).toContain( "Next: githits search-status search-ref-site --wait 20", ); @@ -2179,12 +2178,14 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no result snapshot yet"); + expect(output.split("\n")[0]).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(output).toContain( "- github:expressjs/express#refs/heads/master -> master", ); - expect(output).toContain("Status: indexing | Available now: refs master"); - expect(output).toContain("Search search-ref-123 | 0/1 target ready"); + expect(output).toContain("indexed: refs master"); + expect(output).not.toContain("Search search-ref-123 |"); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", ); @@ -2211,8 +2212,10 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("TIMEOUT - no result snapshot returned"); - expect(output).toContain("Search search-ref-timeout | 0/1 target ready"); + expect(output.split("\n")[0]).toBe( + "No result snapshot | timeout | 0/1 ready", + ); + expect(output).not.toContain("Search search-ref-timeout |"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("longer wait"); expect(output).not.toContain("Search still in progress."); @@ -2268,11 +2271,13 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); + expect(output.split("\n")[0]).toBe( + "1 result | 1 repo code hit | deferred | 1/2 ready", + ); expect(output).toContain( "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); - expect(output).toContain("Search ref-deferred | 1/2 targets ready"); + expect(output).not.toContain("Search ref-deferred |"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("No results"); @@ -2306,12 +2311,12 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe( - "FUTURE_SESSION_STATE - 1 result returned", + "1 result | 1 repo code hit | status unknown | 0/1 ready", ); expect(output).toContain( "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); - expect(output).toContain("Search ref-future | 0/1 target ready"); + expect(output).not.toContain("Search ref-future |"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("No results"); @@ -2339,9 +2344,9 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe( - "DEFERRED - no result snapshot returned", + "No result snapshot | deferred | 0/1 ready", ); - expect(output).toContain("Search ref-deferred-empty | 0/1 target ready"); + expect(output).not.toContain("Search ref-deferred-empty |"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("No results"); expect(output).not.toContain("Indexing/search still in progress"); @@ -2369,7 +2374,9 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("FAILED - no result snapshot returned"); + expect(output.split("\n")[0]).toBe( + "No result snapshot | failed | 0/1 ready", + ); expect(output).not.toContain("Search still in progress."); consoleSpy.mockRestore(); }); @@ -2440,11 +2447,11 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); + expect(output).toMatch(/searched:\s+repository\s+docs/); expect(output).toMatch( - /Available now: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, + /available: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, ); expect(output).not.toContain("Evidence may change."); expect( @@ -2480,10 +2487,10 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); - expect(output).toContain("Unavailable: expressjs.com/en/guide docs"); + expect(output).toMatch(/searched:\s+repository\s+docs/); + expect(output).toContain("unavailable: expressjs.com/en/guide docs"); consoleSpy.mockRestore(); }); @@ -2575,12 +2582,9 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("- site:example.com"); - expect(output).toContain("Suggested sites: site:example.com/docs"); - expect(output).toContain("More suggested sites omitted"); - expect(output).toContain("Search search-ref-123 | completed"); - expect(output).toContain( - "Next: retry one suggested site target explicitly.", - ); + expect(output).toContain("Try: site:example.com/docs"); + expect(output).toContain("+more"); + expect(output).not.toContain("Search search-ref-123 | completed"); consoleSpy.mockRestore(); }); diff --git a/src/commands/search.ts b/src/commands/search.ts index 69cbb17d..181cbdd9 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -154,9 +154,13 @@ export async function searchStatusAction( const SEARCH_DESCRIPTION = `Search code, docs, and symbols across indexed dependencies, repositories, and documentation sites. Repeatable --in targets accept explicit package form (registry:name[@version], -for example npm:express[@version]) or repo form (github:org/repo[#ref|@ref], -github.com/org/repo[#ref|@ref], or https://github.com/org/repo[#ref|@ref]), -or an exact documentation site as site:. Missing or +for example npm:express[@version]). Package targets inspect an indexed +artifact/manifest root, not a full repository. Swift packages use +swift:github.com//; Zig packages use zig:gh//. +Use public GitHub repository targets for full repositories or sibling packages. +Repository targets use github:org/repo[#ref|@ref], +github.com/org/repo[#ref|@ref], or https://github.com/org/repo[#ref|@ref]. +Exact documentation sites use site:. Missing or ambiguous sites may return advisory site targets to retry explicitly. Output uses canonical github:org/repo#ref formatting. Structured flags are AND-combined with the query. Complete by default. Active PENDING, INDEXING, or @@ -201,7 +205,7 @@ export function registerSearchCommand(program: Command) { .argument("", "Search query") .requiredOption( "--in ", - "Search target: registry:name[@version], github:org/repo[#ref|@ref], github.com/org/repo[#ref|@ref], https://github.com/org/repo[#ref|@ref], or site:", + "Search target: registry:name[@version] (artifact/manifest-root scope; Swift: swift:github.com//, Zig: zig:gh//), public GitHub repo github:org/repo[#ref|@ref] for full/sibling-package scope, or site:", collectRepeatable, [] as string[], ) diff --git a/src/container.test.ts b/src/container.test.ts index 31bb7495..345010e5 100644 --- a/src/container.test.ts +++ b/src/container.test.ts @@ -292,7 +292,7 @@ describe("createContainer", () => { globalThis.fetch = originalFetch; await rm(storageRoot, { recursive: true, force: true }); } - }); + }, 20_000); it("rejects insecure service URLs before constructing authenticated clients", async () => { await withEnvVars( diff --git a/src/skills-packaging.test.ts b/src/skills-packaging.test.ts index 2a51c1ae..72acaf3e 100644 --- a/src/skills-packaging.test.ts +++ b/src/skills-packaging.test.ts @@ -19,6 +19,13 @@ const troubleshootingPath = join( ); const githitsMcpSkillPath = join(root, "skills", "githits-mcp", "SKILL.md"); const githitsCodeSkillPath = join(root, "skills", "githits-code", "SKILL.md"); +const githitsCodeReferencePath = join( + root, + "skills", + "githits-code", + "references", + "code-and-docs.md", +); const pluginMaintenanceSkillPath = join( root, ".agents", @@ -57,6 +64,23 @@ function expectNotContainsAllIgnoringWhitespace( } describe("agent skills packaging", () => { + it("documents canonical target guidance for package and repository scope", async () => { + const [mcpContent, codeReference] = await Promise.all([ + read(githitsMcpSkillPath), + read(githitsCodeReferencePath), + ]); + + for (const content of [mcpContent, codeReference]) { + expectContainsAll(content, [ + "swift:github.com//", + "zig:gh//", + "artifact/manifest root", + "public GitHub repository", + "full repositories or sibling packages", + ]); + } + }); + it("packages a public githits-onboarding skill with setup-focused frontmatter", async () => { const content = await read(onboardingSkillPath);