diff --git a/eval/agentic/README.md b/eval/agentic/README.md index cfa0a6b4..52d6e6e1 100644 --- a/eval/agentic/README.md +++ b/eval/agentic/README.md @@ -169,6 +169,7 @@ use at least one agent for quick iteration. | Agent-driven GitHits onboarding and setup UX | `githits-onboarding.md` | | Core global examples, `get_example`, `search_language`, `feedback` | `global-example.md` | | Unified `search` / `search_status` behavior | `unified-search-investigation.md`; use `search-source-ergonomics.md` when changing `search` source-selection arguments or minimal-call guidance | +| Explicit standalone site targets in unified `search` | `site-search-explicit.md` | | Package overview or vulnerability UX, `pkg_info`, `pkg_vulns` | `package-overview-vulnerabilities.md`; use `package-vulnerability-filter.md` for severity/version filtering behavior; use `package-vulnerability-history.md` for historical/non-affecting advisory scope behavior | | Dependency graph UX, `pkg_deps` | `package-dependencies.md` | | Release notes UX, `pkg_changelog` | `package-changelog.md`; use `package-changelog-range.md` for range/body-preview behavior | diff --git a/eval/agentic/workloads/site-search-explicit.md b/eval/agentic/workloads/site-search-explicit.md new file mode 100644 index 00000000..8d509796 --- /dev/null +++ b/eval/agentic/workloads/site-search-explicit.md @@ -0,0 +1,6 @@ +# Workload: Explicit Site Search + +Find documentation on `site:expressjs.com` about Express routers. Summarize the +relevant routing behavior and include source URLs or page IDs inside the +`answer` field. Do not add extra top-level JSON fields beyond the reporting +schema. diff --git a/packages/core-internal/src/services/code-navigation-service.test.ts b/packages/core-internal/src/services/code-navigation-service.test.ts index bc2103c8..b1aedf28 100644 --- a/packages/core-internal/src/services/code-navigation-service.test.ts +++ b/packages/core-internal/src/services/code-navigation-service.test.ts @@ -873,6 +873,216 @@ describe("CodeNavigationServiceImpl", () => { stderrSpy.mockRestore(); }); + it("requests documentation coverage and site fields in the search query", async () => { + const fn = mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + search: { + completed: true, + searchRef: null, + result: { + query: "router", + queryWarnings: [], + sources: ["DOCS"], + results: [], + page: { offset: 0, limit: 20, returned: 0, hasMore: false }, + partialResults: false, + sourceStatus: [], + }, + progress: null, + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await service.search({ + targets: [{ site: "site:expressjs.com" }], + query: "router", + }); + + const [, init] = fn.mock.calls[0] as unknown as [string, RequestInit]; + const query = JSON.parse(init.body as string).query as string; + expect(query).toContain("coverageState"); + expect(query).toContain("frontierRemaining"); + // requestedTargets must select `site`, otherwise standalone site + // targets echo back as empty objects during progress polling. + expect(query).toMatch(/requestedTargets\s*{[^}]*site/); + }); + + it("normalises documentation coverage on source status", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + search: { + completed: true, + searchRef: null, + result: { + query: "router", + queryWarnings: [], + sources: ["DOCS"], + results: [], + page: { offset: 0, limit: 20, returned: 0, hasMore: false }, + partialResults: false, + sourceStatus: [ + { + source: "DOCS", + targetLabel: "site:expressjs.com", + appliedFilters: [], + ignoredFilters: [], + incompatibleFilters: [], + appliedQueryFeatures: [], + ignoredQueryFeatures: [], + incompatibleQueryFeatures: [], + coverage: { + coverageState: "PARTIAL", + pagesCrawled: 42, + frontierRemaining: 158, + note: "Site crawl is in progress", + }, + }, + ], + }, + progress: null, + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + const outcome = await service.search({ + targets: [{ site: "site:expressjs.com" }], + query: "router", + }); + + if (outcome.state !== "completed") throw new Error("expected completed"); + expect(outcome.result.sourceStatus[0]?.coverage).toEqual({ + coverageState: "PARTIAL", + pagesCrawled: 42, + frontierRemaining: 158, + note: "Site crawl is in progress", + }); + }); + + it("drops NONE documentation coverage as carrying no signal", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + search: { + completed: true, + searchRef: null, + result: { + query: "router", + queryWarnings: [], + sources: ["DOCS"], + results: [], + page: { offset: 0, limit: 20, returned: 0, hasMore: false }, + partialResults: false, + sourceStatus: [ + { + source: "DOCS", + targetLabel: "site:expressjs.com", + appliedFilters: [], + ignoredFilters: [], + incompatibleFilters: [], + appliedQueryFeatures: [], + ignoredQueryFeatures: [], + incompatibleQueryFeatures: [], + coverage: { coverageState: "NONE", pagesCrawled: 0 }, + }, + ], + }, + progress: null, + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + const outcome = await service.search({ + targets: [{ site: "site:expressjs.com" }], + query: "router", + }); + + if (outcome.state !== "completed") throw new Error("expected completed"); + expect(outcome.result.sourceStatus[0]?.coverage).toBeUndefined(); + }); + + it("serializes standalone site targets for unified search", async () => { + const fn = mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + search: { + completed: true, + searchRef: null, + result: { + query: "router middleware", + queryWarnings: [], + sources: ["DOCS"], + results: [], + page: { + offset: 0, + limit: 20, + returned: 0, + hasMore: false, + }, + partialResults: false, + sourceStatus: [], + }, + progress: null, + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await service.search({ + targets: [{ site: "site:expressjs.com" }], + query: "router middleware", + sources: ["DOCS"], + }); + + const [, init] = fn.mock.calls[0] as unknown as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.variables.targets).toEqual([{ site: "site:expressjs.com" }]); + }); + it("throws CodeNavigationIndexingError for data-path INDEXING sentinel on grepRepo", async () => { mockFetch(() => Promise.resolve( diff --git a/packages/core-internal/src/services/code-navigation-service.ts b/packages/core-internal/src/services/code-navigation-service.ts index b80deccc..e571e4df 100644 --- a/packages/core-internal/src/services/code-navigation-service.ts +++ b/packages/core-internal/src/services/code-navigation-service.ts @@ -99,6 +99,11 @@ export interface CodeNavigationTarget { gitRef?: string; } +export interface UnifiedSearchTarget extends CodeNavigationTarget { + /** Standalone documentation site target, canonicalized as `site:`. */ + site?: string; +} + export interface IndexResolution { requestedVersion?: string; requestedRef?: string; @@ -114,6 +119,7 @@ export interface TargetResolutionIdentity { repoUrl?: string; gitRef?: string; commitSha?: string; + site?: string; } export type AvailableRef = AvailableVersion; @@ -156,6 +162,35 @@ export type CodeIndexState = | "MISSING" | string; +/** + * Coverage lifecycle for crawled documentation site data. + * + * `PARTIAL` is transient (a crawl is still running, so retrying later can + * return more); `CAPPED` is terminal (a crawl limit stopped indexing, so + * retrying will not help). Both mean served evidence may be incomplete. + */ +export type DocCoverageState = + | "NONE" + | "PARTIAL" + | "CAPPED" + | "COMPLETE" + | string; + +/** + * Coverage metadata for crawled documentation site data. Present on docs + * source status and progress targets when the backend has crawl metadata. + */ +export interface DocCoverage { + coverageState: DocCoverageState; + coverageReason?: string; + pagesCrawled?: number; + frontierRemaining?: number; + artifactOverflowPageCount?: number; + estimatedTotalPages?: number; + /** Backend-owned note suitable for CLI/MCP rendering. Preferred over client wording. */ + note?: string; +} + export type DiscoveryRequestedRefKind = | "OMITTED_VERSION" | "LATEST_VERSION" @@ -165,7 +200,12 @@ export type DiscoveryRequestedRefKind = | "BRANCH" | "SHA"; -export type DiscoveryTargetMode = "PACKAGES" | "REPO" | "MIXED"; +export type DiscoveryTargetMode = + | "PACKAGES" + | "REPO" + | "MIXED" + | "SITES" + | "SITE"; export interface UnifiedSearchFilters { fileIntent?: FileIntent; @@ -176,7 +216,7 @@ export interface UnifiedSearchFilters { } export interface UnifiedSearchParams { - targets: CodeNavigationTarget[]; + targets: UnifiedSearchTarget[]; query: string; sources?: UnifiedSearchSource[]; filters?: UnifiedSearchFilters; @@ -249,6 +289,7 @@ export interface UnifiedSearchSourceStatus { ignoredQueryFeatures: string[]; incompatibleQueryFeatures: string[]; note?: string; + coverage?: DocCoverage; } export interface UnifiedSearchProgressTarget { @@ -262,6 +303,7 @@ export interface UnifiedSearchProgressTarget { availableVersions?: AvailableVersion[]; availableRefs?: AvailableRef[]; suggestedRefs?: SuggestedRef[]; + coverage?: DocCoverage; } export interface UnifiedSearchRequestedTarget { @@ -270,6 +312,7 @@ export interface UnifiedSearchRequestedTarget { version?: string; repoUrl?: string; gitRef?: string; + site?: string; } export interface UnifiedSearchResult { @@ -669,6 +712,22 @@ suggestedRefs { ref }`; +/** + * Crawl coverage for documentation site data. Selected on both docs source + * status and progress targets so callers can distinguish "no matches" from + * "evidence withheld or incomplete because the crawl is partial or capped". + */ +const DOC_COVERAGE_SELECTION = ` +coverage { + coverageState + coverageReason + pagesCrawled + frontierRemaining + artifactOverflowPageCount + estimatedTotalPages + note +}`; + const TARGET_RESOLUTION_SELECTION = ` targetResolution { requested { @@ -822,6 +881,7 @@ query UnifiedSearch( ignoredQueryFeatures incompatibleQueryFeatures note + ${DOC_COVERAGE_SELECTION} } } progress { @@ -841,6 +901,7 @@ query UnifiedSearch( version repoUrl gitRef + site } filters { fileIntent @@ -860,6 +921,7 @@ query UnifiedSearch( requestedRefKind ${TARGET_RESOLUTION_SELECTION} ${DISCOVERY_TARGET_PROGRESS_RETRY_SELECTION} + ${DOC_COVERAGE_SELECTION} } expiresAt } @@ -885,6 +947,7 @@ query UnifiedSearchStatus($searchRef: String!, $includeResults: Boolean!) { version repoUrl gitRef + site } filters { fileIntent @@ -904,6 +967,7 @@ query UnifiedSearchStatus($searchRef: String!, $includeResults: Boolean!) { requestedRefKind ${TARGET_RESOLUTION_SELECTION} ${DISCOVERY_TARGET_PROGRESS_RETRY_SELECTION} + ${DOC_COVERAGE_SELECTION} } expiresAt results { @@ -970,6 +1034,7 @@ query UnifiedSearchStatus($searchRef: String!, $includeResults: Boolean!) { ignoredQueryFeatures incompatibleQueryFeatures note + ${DOC_COVERAGE_SELECTION} } } } @@ -1072,6 +1137,7 @@ const targetResolutionIdentitySchema = z repoUrl: z.string().nullable().optional(), gitRef: z.string().nullable().optional(), commitSha: z.string().nullable().optional(), + site: z.string().nullable().optional(), }) .nullable() .optional(); @@ -1155,6 +1221,19 @@ const unifiedSearchPageInfoSchema = z.object({ hasMore: z.boolean(), }); +const docCoverageSchema = z + .object({ + coverageState: z.string(), + coverageReason: z.string().nullable().optional(), + pagesCrawled: z.number().int().nullable().optional(), + frontierRemaining: z.number().int().nullable().optional(), + artifactOverflowPageCount: z.number().int().nullable().optional(), + estimatedTotalPages: z.number().int().nullable().optional(), + note: z.string().nullable().optional(), + }) + .nullable() + .optional(); + const unifiedSearchSourceStatusSchema = z.object({ source: unifiedSearchSourceSchema, targetLabel: z.string(), @@ -1172,6 +1251,7 @@ const unifiedSearchSourceStatusSchema = z.object({ ignoredQueryFeatures: z.array(z.string()), incompatibleQueryFeatures: z.array(z.string()), note: z.string().nullable().optional(), + coverage: docCoverageSchema, }); const unifiedSearchResultSchema = z.object({ @@ -1215,6 +1295,7 @@ const unifiedSearchProgressTargetSchema = z.object({ availableVersions: z.array(availableVersionSchema).nullable().optional(), availableRefs: z.array(availableVersionSchema).nullable().optional(), suggestedRefs: z.array(availableVersionSchema).nullable().optional(), + coverage: docCoverageSchema, }); const unifiedSearchRequestedTargetSchema = z.object({ @@ -1223,6 +1304,7 @@ const unifiedSearchRequestedTargetSchema = z.object({ version: z.string().nullable().optional(), repoUrl: z.string().nullable().optional(), gitRef: z.string().nullable().optional(), + site: z.string().nullable().optional(), }); const unifiedSearchProgressSchema = z.object({ @@ -1760,6 +1842,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { version: target.version, repoUrl: target.repoUrl, gitRef: target.gitRef, + site: target.site, })), query: params.query, sources: params.sources, @@ -2239,6 +2322,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { ignoredQueryFeatures: entry.ignoredQueryFeatures, incompatibleQueryFeatures: entry.incompatibleQueryFeatures, note: entry.note ?? undefined, + coverage: normaliseDocCoverage(entry.coverage), })), }; } @@ -2265,6 +2349,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { version: target.version ?? undefined, repoUrl: target.repoUrl ?? undefined, gitRef: target.gitRef ?? undefined, + site: target.site ?? undefined, })), filters: normaliseProgressFilters(progress.filters), limit: progress.limit ?? undefined, @@ -2280,6 +2365,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { availableVersions: normaliseAvailableVersions(target.availableVersions), availableRefs: normaliseAvailableVersions(target.availableRefs), suggestedRefs: normaliseAvailableVersions(target.suggestedRefs), + coverage: normaliseDocCoverage(target.coverage), })), expiresAt: progress.expiresAt ?? undefined, }; @@ -2910,6 +2996,34 @@ function normaliseTargetResolution( }; } +/** + * Normalise crawl coverage, dropping the entry entirely when the backend + * reports no computed coverage. `NONE` carries no actionable signal, so + * collapsing it here keeps downstream rendering free of empty states. + */ +function normaliseDocCoverage( + coverage: z.infer, +): DocCoverage | undefined { + if (!coverage) return undefined; + if (coverage.coverageState === "NONE") return undefined; + const out: DocCoverage = { coverageState: coverage.coverageState }; + if (coverage.coverageReason) out.coverageReason = coverage.coverageReason; + if (typeof coverage.pagesCrawled === "number") { + out.pagesCrawled = coverage.pagesCrawled; + } + if (typeof coverage.frontierRemaining === "number") { + out.frontierRemaining = coverage.frontierRemaining; + } + if (typeof coverage.artifactOverflowPageCount === "number") { + out.artifactOverflowPageCount = coverage.artifactOverflowPageCount; + } + if (typeof coverage.estimatedTotalPages === "number") { + out.estimatedTotalPages = coverage.estimatedTotalPages; + } + if (coverage.note) out.note = coverage.note; + return out; +} + function normaliseTargetResolutionIdentity( identity: z.infer, ): TargetResolutionIdentity | undefined { @@ -2922,6 +3036,7 @@ function normaliseTargetResolutionIdentity( if (identity.repoUrl) out.repoUrl = identity.repoUrl; if (identity.gitRef) out.gitRef = identity.gitRef; if (identity.commitSha) out.commitSha = identity.commitSha; + if (identity.site) out.site = identity.site; return Object.keys(out).length > 0 ? out : undefined; } @@ -2938,7 +3053,13 @@ function isAuthMessage(message: string): boolean { function normaliseTargetMode( value: string | null | undefined, ): DiscoveryTargetMode | undefined { - if (value === "PACKAGES" || value === "REPO" || value === "MIXED") { + if ( + value === "PACKAGES" || + value === "REPO" || + value === "MIXED" || + value === "SITES" || + value === "SITE" + ) { return value; } return undefined; diff --git a/packages/mcp/src/shared/code-navigation-target.test.ts b/packages/mcp/src/shared/code-navigation-target.test.ts index a73f5861..e4910f81 100644 --- a/packages/mcp/src/shared/code-navigation-target.test.ts +++ b/packages/mcp/src/shared/code-navigation-target.test.ts @@ -92,6 +92,12 @@ describe("parseCodeNavigationTargetSpec", () => { ); }); + it("rejects standalone site targets because code navigation tools require package or repo targets", () => { + expect(() => parseCodeNavigationTargetSpec("site:expressjs.com")).toThrow( + 'Unsupported registry "site"', + ); + }); + it("rejects unknown repository-looking targets with target syntax guidance", () => { expect(() => parseCodeNavigationTargetSpec("gitlab.com/org/repo")).toThrow( "Expected package target :[@]", diff --git a/packages/mcp/src/shared/target-resolution.test.ts b/packages/mcp/src/shared/target-resolution.test.ts index 8d98f610..c5e37e8d 100644 --- a/packages/mcp/src/shared/target-resolution.test.ts +++ b/packages/mcp/src/shared/target-resolution.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { buildRetryCandidateLine, buildTargetResolutionNotes, + formatTargetResolutionIdentity, projectTargetResolution, } from "./target-resolution.js"; @@ -79,6 +80,22 @@ describe("target-resolution helpers", () => { ); }); + it("projects and renders standalone site identities", () => { + const projected = projectTargetResolution({ + requested: { kind: "site", site: "site:expressjs.com" }, + resolvedRequested: { site: "site:expressjs.com" }, + served: { site: "site:expressjs.com" }, + freshness: "current", + availableVersions: [], + availableRefs: [], + }); + + expect(projected?.requested?.site).toBe("site:expressjs.com"); + expect(formatTargetResolutionIdentity(projected?.requested)).toBe( + "site:expressjs.com", + ); + }); + it("renders requested_ref_indexing reason with requested and indexing refs", () => { const notes = buildTargetResolutionNotes({ requested: { diff --git a/packages/mcp/src/shared/target-resolution.ts b/packages/mcp/src/shared/target-resolution.ts index e35e6f0f..23541273 100644 --- a/packages/mcp/src/shared/target-resolution.ts +++ b/packages/mcp/src/shared/target-resolution.ts @@ -9,6 +9,7 @@ export interface LeanTargetResolutionIdentity { repoUrl?: string; gitRef?: string; commitSha?: string; + site?: string; } export interface LeanAvailableArtifact { @@ -209,6 +210,7 @@ function projectIdentity( if (identity.repoUrl) out.repoUrl = identity.repoUrl; if (identity.gitRef) out.gitRef = identity.gitRef; if (identity.commitSha) out.commitSha = identity.commitSha; + if (identity.site) out.site = identity.site; return out; } @@ -234,6 +236,7 @@ export function formatTargetResolutionIdentity( const commit = identity.commitSha ? `@${shortSha(identity.commitSha)}` : ""; return `${target}${commit}`; } + if (identity.site) return identity.site; return ( identity.gitRef ?? identity.version ?? identity.commitSha ?? identity.kind ); diff --git a/packages/mcp/src/shared/unified-search-request.ts b/packages/mcp/src/shared/unified-search-request.ts index 41ce1282..a52bebe0 100644 --- a/packages/mcp/src/shared/unified-search-request.ts +++ b/packages/mcp/src/shared/unified-search-request.ts @@ -1,11 +1,11 @@ import type { - CodeNavigationTarget, FileIntent, SymbolCategory, SymbolKind, UnifiedSearchFilters, UnifiedSearchParams, UnifiedSearchSource, + UnifiedSearchTarget, } from "@githits/core-internal"; import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; import { InvalidArgumentError } from "./package-spec.js"; @@ -13,8 +13,8 @@ import { InvalidArgumentError } from "./package-spec.js"; export const DEFAULT_UNIFIED_SEARCH_LIMIT = 10; export interface UnifiedSearchRequestInput { - target?: CodeNavigationTarget; - targets?: CodeNavigationTarget[]; + target?: UnifiedSearchTarget; + targets?: UnifiedSearchTarget[]; query: string; sources?: UnifiedSearchSource[]; kind?: SymbolKind; @@ -82,9 +82,9 @@ function isDocsOnlySource(sources: UnifiedSearchSource[] | undefined): boolean { } function resolveTargets( - target: CodeNavigationTarget | undefined, - targets: CodeNavigationTarget[] | undefined, -): CodeNavigationTarget[] { + target: UnifiedSearchTarget | undefined, + targets: UnifiedSearchTarget[] | undefined, +): UnifiedSearchTarget[] { const nonEmptyTargets = targets?.length ? targets : undefined; if (target && nonEmptyTargets) { throw new InvalidArgumentError( @@ -99,7 +99,7 @@ function resolveTargets( ); } - const deduped: CodeNavigationTarget[] = []; + const deduped: UnifiedSearchTarget[] = []; const seen = new Set(); for (const entry of resolved) { const key = JSON.stringify(entry); diff --git a/packages/mcp/src/shared/unified-search-response.test.ts b/packages/mcp/src/shared/unified-search-response.test.ts index 3172fddc..6e95c4be 100644 --- a/packages/mcp/src/shared/unified-search-response.test.ts +++ b/packages/mcp/src/shared/unified-search-response.test.ts @@ -649,6 +649,256 @@ describe("buildUnifiedSearchSuccessPayload", () => { expect(payload.warnings?.join("\n")).toContain("queryable now"); expect(payload.warnings?.join("\n")).toContain("suggested refs"); }); + + it("surfaces docs coverage on progress targets while polling", () => { + const payload = buildUnifiedSearchSuccessPayload( + { targets: [{ site: "site:expressjs.com" }], query: "router" }, + "router", + "router", + { + state: "incomplete", + completed: false, + searchRef: "search-ref-coverage", + progress: { + searchRef: "search-ref-coverage", + status: "INDEXING", + targetsTotal: 1, + targetsReady: 0, + elapsedMs: 200, + query: "router", + queryWarnings: [], + sources: ["DOCS"], + targets: [ + { + requested: "site:expressjs.com", + coverage: { coverageState: "PARTIAL", pagesCrawled: 12 }, + }, + ], + }, + }, + ); + + expect(payload.completed).toBe(false); + if (payload.completed) throw new Error("expected incomplete payload"); + expect(payload.progress?.targets?.[0]?.coverage?.coverageState).toBe( + "PARTIAL", + ); + expect(payload.warnings?.join("\n")).toContain("docs coverage partial"); + }); + + it("warns about partial docs coverage even when the search reports completed", () => { + // Regression for the crawl-in-progress case: the backend can report + // `completed: true` with zero results while a site re-crawl withholds + // already-indexed content. Without a coverage warning the caller reads + // an authoritative "no documentation exists". + const payload = buildUnifiedSearchSuccessPayload( + { targets: [{ site: "site:expressjs.com" }], query: "router" }, + "router", + "router", + { + state: "completed", + completed: true, + result: { + query: "router", + queryWarnings: [], + sources: ["DOCS"], + results: [], + page: { offset: 0, limit: 10, returned: 0, hasMore: false }, + partialResults: false, + sourceStatus: [ + { + source: "DOCS", + targetLabel: "site:expressjs.com", + appliedFilters: [], + ignoredFilters: [], + incompatibleFilters: [], + appliedQueryFeatures: [], + ignoredQueryFeatures: [], + incompatibleQueryFeatures: [], + coverage: { + coverageState: "PARTIAL", + pagesCrawled: 42, + frontierRemaining: 158, + }, + }, + ], + }, + }, + ); + + expect(payload.completed).toBe(true); + expect(payload.sourceStatus?.[0]?.coverage?.coverageState).toBe("PARTIAL"); + const warnings = payload.warnings?.join("\n") ?? ""; + expect(warnings).toContain("docs coverage partial"); + expect(warnings).toContain("42 pages indexed"); + expect(warnings).toContain("158 known URLs unindexed"); + expect(warnings).toContain("retry shortly"); + }); + + it("prefers the backend coverage note over client wording", () => { + const payload = buildUnifiedSearchSuccessPayload( + { targets: [{ site: "site:expressjs.com" }], query: "router" }, + "router", + "router", + { + state: "completed", + completed: true, + result: { + query: "router", + queryWarnings: [], + sources: ["DOCS"], + results: [], + page: { offset: 0, limit: 10, returned: 0, hasMore: false }, + partialResults: false, + sourceStatus: [ + { + source: "DOCS", + targetLabel: "site:expressjs.com", + appliedFilters: [], + ignoredFilters: [], + incompatibleFilters: [], + appliedQueryFeatures: [], + ignoredQueryFeatures: [], + incompatibleQueryFeatures: [], + coverage: { + coverageState: "PARTIAL", + note: "Site crawl is in progress", + }, + }, + ], + }, + }, + ); + + const warnings = payload.warnings?.join("\n") ?? ""; + expect(warnings).toContain("Site crawl is in progress"); + expect(warnings).not.toContain("docs coverage partial"); + }); + + it("describes capped coverage as terminal without retry advice", () => { + const payload = buildUnifiedSearchSuccessPayload( + { targets: [{ site: "site:expressjs.com" }], query: "router" }, + "router", + "router", + { + state: "completed", + completed: true, + result: { + query: "router", + queryWarnings: [], + sources: ["DOCS"], + results: [], + page: { offset: 0, limit: 10, returned: 0, hasMore: false }, + partialResults: false, + sourceStatus: [ + { + source: "DOCS", + targetLabel: "site:expressjs.com", + appliedFilters: [], + ignoredFilters: [], + incompatibleFilters: [], + appliedQueryFeatures: [], + ignoredQueryFeatures: [], + incompatibleQueryFeatures: [], + coverage: { + coverageState: "CAPPED", + coverageReason: "page_limit_reached", + pagesCrawled: 500, + }, + }, + ], + }, + }, + ); + + const warnings = payload.warnings?.join("\n") ?? ""; + expect(warnings).toContain("capped by a crawl limit"); + expect(warnings).toContain("page_limit_reached"); + expect(warnings).not.toContain("retry shortly"); + }); + + it("stays silent for complete docs coverage", () => { + const payload = buildUnifiedSearchSuccessPayload( + { targets: [{ site: "site:expressjs.com" }], query: "router" }, + "router", + "router", + { + state: "completed", + completed: true, + result: { + query: "router", + queryWarnings: [], + sources: ["DOCS"], + results: [], + page: { offset: 0, limit: 10, returned: 0, hasMore: false }, + partialResults: false, + sourceStatus: [ + { + source: "DOCS", + targetLabel: "site:expressjs.com", + appliedFilters: [], + ignoredFilters: [], + incompatibleFilters: [], + appliedQueryFeatures: [], + ignoredQueryFeatures: [], + incompatibleQueryFeatures: [], + coverage: { coverageState: "COMPLETE", pagesCrawled: 200 }, + }, + ], + }, + }, + ); + + expect(payload.sourceStatus).toBeUndefined(); + expect(payload.warnings).toBeUndefined(); + }); + + it("preserves site requestedTargets and targetResolution in progress payloads", () => { + const payload = buildUnifiedSearchSuccessPayload( + { targets: [{ site: "site:expressjs.com" }], query: "router" }, + "router", + "router", + { + state: "incomplete", + completed: false, + searchRef: "search-ref-site", + progress: { + searchRef: "search-ref-site", + status: "INDEXING", + targetsTotal: 1, + targetsReady: 0, + elapsedMs: 200, + query: "router", + queryWarnings: [], + sources: ["DOCS"], + requestedTargets: [{ site: "site:expressjs.com" }], + targets: [ + { + requested: "site:expressjs.com", + freshness: "INDEXING", + targetResolution: { + requested: { kind: "site", site: "site:expressjs.com" }, + freshness: "indexing", + availableVersions: [], + availableRefs: [], + }, + }, + ], + }, + }, + ); + + expect(payload.completed).toBe(false); + if (payload.completed) { + throw new Error("expected incomplete payload"); + } + expect(payload.progress?.requestedTargets).toEqual([ + { site: "site:expressjs.com" }, + ]); + expect( + payload.progress?.targets?.[0]?.targetResolution?.requested?.site, + ).toBe("site:expressjs.com"); + }); }); describe("buildSourceStatusWarnings — sourceStatus → warnings promotion", () => { diff --git a/packages/mcp/src/shared/unified-search-response.ts b/packages/mcp/src/shared/unified-search-response.ts index 11610fa4..2c7f9e9c 100644 --- a/packages/mcp/src/shared/unified-search-response.ts +++ b/packages/mcp/src/shared/unified-search-response.ts @@ -1,4 +1,5 @@ import type { + DocCoverage, UnifiedSearchCompleted, UnifiedSearchHit, UnifiedSearchOutcome, @@ -98,6 +99,7 @@ export interface UnifiedSearchProgressPayload { version?: string; repoUrl?: string; gitRef?: string; + site?: string; }>; filters?: UnifiedSearchQueryEcho["filters"]; limit?: number; @@ -113,6 +115,7 @@ export interface UnifiedSearchProgressPayload { availableVersions?: LeanAvailableArtifact[]; availableRefs?: LeanAvailableArtifact[]; suggestedRefs?: LeanAvailableArtifact[]; + coverage?: LeanDocCoverage; }>; expiresAt?: string; next?: string; @@ -133,6 +136,21 @@ export interface UnifiedSearchSourceStatusPayload { ignoredQueryFeatures?: string[]; incompatibleQueryFeatures?: string[]; note?: string; + coverage?: LeanDocCoverage; +} + +/** + * Lean projection of documentation crawl coverage. Only the fields that + * change a caller's decision are carried: the state, why it stopped, and + * enough page counts to judge magnitude. + */ +export interface LeanDocCoverage { + coverageState: string; + coverageReason?: string; + pagesCrawled?: number; + frontierRemaining?: number; + estimatedTotalPages?: number; + note?: string; } export interface UnifiedSearchCompletedPayload { @@ -600,6 +618,8 @@ function compactProgressTarget( if (target.suggestedRefs?.length) { payload.suggestedRefs = target.suggestedRefs; } + const coverage = projectDocCoverage(target.coverage); + if (coverage) payload.coverage = coverage; return Object.keys(payload).length > 0 ? payload : undefined; } @@ -683,6 +703,8 @@ function progressTargetResolutionWarning( const notes = buildTargetResolutionNotes( target.targetResolution ?? buildResolutionFromRetryCandidates(target), ); + const coverage = docCoverageWarningReason(target.coverage); + if (coverage) notes.push(coverage); return notes.length > 0 ? notes.join(" ") : undefined; } @@ -742,6 +764,48 @@ function parsePackageVersionLabel( }; } +/** + * Build the human-readable reason for incomplete documentation coverage. + * + * The backend note is preferred verbatim when present — the schema + * documents it as "suitable for CLI/MCP rendering", so phrasing stays + * backend-owned and can improve without a client release. Client wording + * is only a fallback, and distinguishes transient `PARTIAL` (retrying can + * return more) from terminal `CAPPED` (a crawl limit stopped indexing, so + * retrying will not). + */ +function docCoverageWarningReason( + coverage: LeanDocCoverage | undefined, +): string | undefined { + if (!coverage) return undefined; + const scale = docCoverageScale(coverage); + if (coverage.note) return `${coverage.note}${scale}`; + if (coverage.coverageState === "PARTIAL") { + return `docs coverage partial — site crawl in progress, evidence may be incomplete${scale}; retry shortly`; + } + if (coverage.coverageState === "CAPPED") { + const reason = coverage.coverageReason + ? ` (${coverage.coverageReason})` + : ""; + return `docs coverage capped by a crawl limit${reason} — evidence may be incomplete${scale}`; + } + return undefined; +} + +/** Render page counts when known, so callers can judge how much is missing. */ +function docCoverageScale(coverage: LeanDocCoverage): string { + const parts: string[] = []; + if (typeof coverage.pagesCrawled === "number") { + parts.push(`${coverage.pagesCrawled} pages indexed`); + } + if (typeof coverage.frontierRemaining === "number") { + parts.push(`${coverage.frontierRemaining} known URLs unindexed`); + } else if (typeof coverage.estimatedTotalPages === "number") { + parts.push(`~${coverage.estimatedTotalPages} pages estimated`); + } + return parts.length > 0 ? ` [${parts.join(", ")}]` : ""; +} + function warningForEntry( entry: UnifiedSearchSourceStatusPayload, options: { completed?: boolean }, @@ -764,6 +828,8 @@ function warningForEntry( ); if (targetResolutionWarning) reasons.push(targetResolutionWarning); } + const coverageReason = docCoverageWarningReason(entry.coverage); + if (coverageReason) reasons.push(coverageReason); if (entry.incompatibleQueryFeatures?.length) { reasons.push( `incompatible query features [${entry.incompatibleQueryFeatures.join(", ")}]`, @@ -860,6 +926,36 @@ function targetResolutionWarningForEntry( return notes.length > 0 ? notes.join(" ") : undefined; } +/** + * Project crawl coverage into the lean payload shape, keeping only states + * that mean served evidence may be incomplete. `COMPLETE` is dropped so + * healthy site searches stay free of noise. + */ +function projectDocCoverage( + coverage: DocCoverage | undefined, +): LeanDocCoverage | undefined { + if (!coverage) return undefined; + if ( + coverage.coverageState !== "PARTIAL" && + coverage.coverageState !== "CAPPED" + ) { + return undefined; + } + const out: LeanDocCoverage = { coverageState: coverage.coverageState }; + if (coverage.coverageReason) out.coverageReason = coverage.coverageReason; + if (typeof coverage.pagesCrawled === "number") { + out.pagesCrawled = coverage.pagesCrawled; + } + if (typeof coverage.frontierRemaining === "number") { + out.frontierRemaining = coverage.frontierRemaining; + } + if (typeof coverage.estimatedTotalPages === "number") { + out.estimatedTotalPages = coverage.estimatedTotalPages; + } + if (coverage.note) out.note = coverage.note; + return out; +} + function compactSourceStatus( sourceStatus: UnifiedSearchSourceStatus[] | undefined, options: { completed?: boolean } = {}, @@ -932,6 +1028,17 @@ function compactSourceStatusEntry( payload.codeIndexState = entry.codeIndexState; interesting = true; } + // Documentation crawl coverage. Deliberately NOT suppressed when + // `options.completed` is true: a completed search over a partially + // crawled site is exactly the case where evidence may be missing while + // the response looks authoritative, so the caller must still be told. + // `COMPLETE` carries no actionable signal and stays silent; `NONE` is + // already dropped upstream. + const coverage = projectDocCoverage(entry.coverage); + if (coverage) { + payload.coverage = coverage; + interesting = true; + } if (typeof entry.resultCount === "number" && entry.resultCount > 0) { payload.resultCount = entry.resultCount; } diff --git a/packages/mcp/src/shared/unified-search-target.test.ts b/packages/mcp/src/shared/unified-search-target.test.ts index a20bdc82..48d3ff14 100644 --- a/packages/mcp/src/shared/unified-search-target.test.ts +++ b/packages/mcp/src/shared/unified-search-target.test.ts @@ -42,12 +42,32 @@ describe("parseUnifiedSearchTargetSpec", () => { }); }); + it("accepts standalone indexed documentation site targets", () => { + expect(parseUnifiedSearchTargetSpec("site:ExpressJS.com")).toEqual({ + site: "site:expressjs.com", + }); + }); + + it("normalises URL-shaped site targets to canonical site labels", () => { + expect( + parseUnifiedSearchTargetSpec("site:https://expressjs.com/en/guide/"), + ).toEqual({ + site: "site:expressjs.com/en/guide", + }); + }); + it("rejects empty targets", () => { expect(() => parseUnifiedSearchTargetSpec(" ")).toThrow( "Target spec cannot be empty.", ); }); + it("rejects empty site targets", () => { + expect(() => parseUnifiedSearchTargetSpec("site: ")).toThrow( + "Site target cannot be empty.", + ); + }); + it("rejects package targets without an explicit registry", () => { expect(() => parseUnifiedSearchTargetSpec("express")).toThrow( "Expected package target :[@]", diff --git a/packages/mcp/src/shared/unified-search-target.ts b/packages/mcp/src/shared/unified-search-target.ts index aa52d3c3..b0a4a43c 100644 --- a/packages/mcp/src/shared/unified-search-target.ts +++ b/packages/mcp/src/shared/unified-search-target.ts @@ -1,4 +1,4 @@ -import type { CodeNavigationTarget } from "@githits/core-internal"; +import type { UnifiedSearchTarget } from "@githits/core-internal"; import { toCodeNavigationRegistry } from "./code-navigation.js"; import { InvalidArgumentError, @@ -14,12 +14,16 @@ import { export function parseUnifiedSearchTargetSpec( spec: string, -): CodeNavigationTarget { +): UnifiedSearchTarget { const trimmed = spec.trim(); if (trimmed.length === 0) { throw new InvalidArgumentError("Target spec cannot be empty."); } + if (isSiteTargetSpec(trimmed)) { + return { site: normaliseSiteTargetSpec(trimmed) }; + } + if (isRepositoryTargetSpec(trimmed)) { return parseRepositoryTargetSpec(trimmed); } @@ -43,3 +47,43 @@ export function parseUnifiedSearchTargetSpec( version: parsed.version, }; } + +function isSiteTargetSpec(spec: string): boolean { + return spec.toLowerCase().startsWith("site:"); +} + +function normaliseSiteTargetSpec(spec: string): string { + const value = spec.slice("site:".length).trim(); + if (value.length === 0) { + throw new InvalidArgumentError( + "Site target cannot be empty. Expected site: for an already-indexed documentation site.", + ); + } + + let host: string; + let path: string; + try { + if (/^https?:\/\//i.test(value)) { + const url = new URL(value); + host = url.host; + path = url.pathname; + } else { + const slashIndex = value.indexOf("/"); + host = slashIndex === -1 ? value : value.slice(0, slashIndex); + path = slashIndex === -1 ? "" : value.slice(slashIndex); + } + } catch { + throw new InvalidArgumentError( + `Invalid site target ${JSON.stringify(spec)}. Expected site: or site:https://.`, + ); + } + + const canonical = `${host.toLowerCase()}${path}`.replace(/\/+$/, ""); + if (canonical.length === 0 || /\s/.test(canonical)) { + throw new InvalidArgumentError( + `Invalid site target ${JSON.stringify(spec)}. Expected site: for an already-indexed documentation site.`, + ); + } + + return `site:${canonical}`; +} diff --git a/packages/mcp/src/tools/code-navigation-shared.ts b/packages/mcp/src/tools/code-navigation-shared.ts index 3a927959..774aedad 100644 --- a/packages/mcp/src/tools/code-navigation-shared.ts +++ b/packages/mcp/src/tools/code-navigation-shared.ts @@ -18,42 +18,47 @@ import { errorResult, type ToolResult } from "./types.js"; // src/shared/code-navigation-defaults.ts per the CLI/MCP parity rules. export { DEFAULT_WAIT_TIMEOUT_MS } from "../shared/code-navigation-defaults.js"; -export const structuredCodeTargetSchema: z.ZodType = z - .object({ - registry: z - .enum(PKGSEER_REGISTRY_ARGS) - .optional() - .describe( - `Package registry (${PKGSEER_REGISTRY_LIST}). Required for package scope.`, - ), - package_name: z - .string() - .max(255) - .optional() - .describe("Package name. Required for package scope."), - version: z - .string() - .max(100) - .optional() - .describe( - "Package version, e.g. '4.18.2' (defaults to latest). For package scope only.", - ), - repo_url: z - .string() - .optional() - .describe( - "Repository URL (GitHub). Required for repo scope. Example: https://github.com/expressjs/express", - ), - git_ref: z - .string() - .optional() - .describe( - "Git ref - tag, branch, commit, or HEAD. Omit with repo_url to request the backend-resolved default branch.", - ), - }) - .describe( +const structuredCodeTargetShape: z.ZodRawShape = { + registry: z + .enum(PKGSEER_REGISTRY_ARGS) + .optional() + .describe( + `Package registry (${PKGSEER_REGISTRY_LIST}). Required for package scope.`, + ), + package_name: z + .string() + .max(255) + .optional() + .describe("Package name. Required for package scope."), + version: z + .string() + .max(100) + .optional() + .describe( + "Package version, e.g. '4.18.2' (defaults to latest). For package scope only.", + ), + repo_url: z + .string() + .optional() + .describe( + "Repository URL (GitHub). Required for repo scope. Example: https://github.com/expressjs/express", + ), + git_ref: z + .string() + .optional() + .describe( + "Git ref - tag, branch, commit, or HEAD. Omit with repo_url to request the backend-resolved default branch.", + ), +}; + +export const structuredCodeTargetObject: z.ZodObject = z.object( + structuredCodeTargetShape, +); + +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).", - ); + ) as z.ZodType; export const codeTargetSchema: z.ZodType = z.union([ structuredCodeTargetSchema, diff --git a/packages/mcp/src/tools/search.test.ts b/packages/mcp/src/tools/search.test.ts index 125be3f3..1bc4ce86 100644 --- a/packages/mcp/src/tools/search.test.ts +++ b/packages/mcp/src/tools/search.test.ts @@ -253,6 +253,88 @@ describe("searchTool", () => { ); }); + it("accepts compact standalone site string targets", async () => { + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); + const tool = createSearchTool(createMockCodeNavigationService({ search })); + + await tool.handler( + { + query: "router middleware", + target: "site:expressjs.com", + }, + {}, + ); + + expect(search).toHaveBeenCalledWith( + expect.objectContaining({ + targets: [{ site: "site:expressjs.com" }], + }), + ); + }); + + it("accepts structured standalone site targets", async () => { + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); + const tool = createSearchTool(createMockCodeNavigationService({ search })); + + await tool.handler( + { + query: "router middleware", + target: { site: "https://expressjs.com/" }, + }, + {}, + ); + + const call = search.mock.calls[0]?.[0]; + expect(call?.targets).toEqual([{ site: "site:expressjs.com" }]); + }); + + it("dedupes equivalent standalone site targets after canonicalization", async () => { + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); + const tool = createSearchTool(createMockCodeNavigationService({ search })); + + await tool.handler( + { + query: "router middleware", + targets: ["site:ExpressJS.com", "site:https://expressjs.com/"], + }, + {}, + ); + + const call = search.mock.calls[0]?.[0]; + expect(call?.targets).toEqual([{ site: "site:expressjs.com" }]); + }); + + it("rejects site targets mixed with package fields", async () => { + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); + const tool = createSearchTool(createMockCodeNavigationService({ search })); + + const result = await tool.handler( + { + query: "router middleware", + target: { + registry: "npm", + package_name: "express", + site: "expressjs.com", + }, + }, + {}, + ); + + expect(result.isError).toBe(true); + expect(search).not.toHaveBeenCalled(); + expect(JSON.parse(result.content[0]?.text ?? "{}")).toMatchObject({ + code: "INVALID_ARGUMENT", + }); + }); + it("returns invalid-argument error when target is missing", async () => { const tool = createSearchTool(createMockCodeNavigationService()); diff --git a/packages/mcp/src/tools/search.ts b/packages/mcp/src/tools/search.ts index 59a06790..363e38cb 100644 --- a/packages/mcp/src/tools/search.ts +++ b/packages/mcp/src/tools/search.ts @@ -1,6 +1,6 @@ import type { CodeNavigationService, - CodeNavigationTarget, + UnifiedSearchTarget, } from "@githits/core-internal"; import { z } from "zod"; import { @@ -22,8 +22,8 @@ import { renderUnifiedSearchSuccess, } from "../shared/unified-search-text.js"; import { - type CodeTargetArg, - structuredCodeTargetSchema, + type StructuredCodeTargetArg, + structuredCodeTargetObject, } from "./code-navigation-shared.js"; import { SEARCH_GUARDRAIL } from "./guardrails.js"; import { addLocalMcpAuthAction, mcpMappedErrorResult } from "./shared.js"; @@ -43,8 +43,8 @@ type ResolvedSearchTarget = Exclude< export interface SearchArgs { query: string; - target?: CodeTargetArg; - targets?: CodeTargetArg[]; + target?: SearchTargetArg; + targets?: SearchTargetArg[]; source?: "docs" | "code" | "symbol"; category?: "callable" | "type" | "module" | "data" | "documentation"; kind?: @@ -96,8 +96,23 @@ export interface SearchArgs { format?: "json" | "text" | "text-v1"; } +interface StructuredSearchTargetArg extends StructuredCodeTargetArg { + site?: string; +} + +type SearchTargetArg = StructuredSearchTargetArg | string; + +const structuredSearchTargetSchema: z.ZodType = + structuredCodeTargetObject + .extend({ + site: z.string().optional(), + }) + .describe( + "Target: provide registry + package_name (package scope) or repo_url with optional git_ref (repo scope; omitted ref means default branch intent).", + ); + const searchTargetSchema = z.union([ - structuredCodeTargetSchema, + structuredSearchTargetSchema, z .string() .min(1) @@ -316,7 +331,8 @@ function isBlankSearchTarget( normaliseOptionalValue(target.package_name) || normaliseOptionalValue(target.version) || normaliseOptionalValue(target.repo_url) || - normaliseOptionalValue(target.git_ref) + normaliseOptionalValue(target.git_ref) || + normaliseOptionalValue(target.site) ); } @@ -333,8 +349,8 @@ function isResolvedSearchTarget( } function resolveSearchTarget( - target: CodeTargetArg, -): CodeNavigationTarget | ToolResult { + target: SearchTargetArg, +): UnifiedSearchTarget | ToolResult { if (typeof target === "string") { try { return parseUnifiedSearchTargetSpec(target); @@ -349,18 +365,28 @@ function resolveSearchTarget( const version = normaliseOptionalValue(target.version); const repoUrl = normaliseOptionalValue(target.repo_url); const gitRef = normaliseOptionalValue(target.git_ref); + const site = normaliseOptionalValue(target.site); const hasPackageTarget = registry !== undefined || packageName !== undefined; const hasRepoTarget = repoUrl !== undefined || gitRef !== undefined; - if (hasPackageTarget && hasRepoTarget) { + const hasSiteTarget = site !== undefined; + const targetModeCount = [ + hasPackageTarget, + hasRepoTarget, + hasSiteTarget, + ].filter(Boolean).length; + if (targetModeCount > 1) { return invalidSearchTargetResult( - "Invalid target: provide either registry + package_name or repo_url with optional git_ref, not both.", + "Invalid target: provide exactly one of registry + package_name, repo_url with optional git_ref, or site.", ); } - if (!hasPackageTarget && !hasRepoTarget) { + if (targetModeCount === 0) { return invalidSearchTargetResult( - "Missing target: provide registry + package_name or repo_url.", + "Missing target: provide registry + package_name, repo_url, or site.", ); } + if (hasSiteTarget) { + return { site: normaliseStructuredSiteTarget(site) }; + } if (hasPackageTarget) { if (!registry || !packageName) { return invalidSearchTargetResult( @@ -381,6 +407,16 @@ function resolveSearchTarget( return { repoUrl, gitRef }; } +function normaliseStructuredSiteTarget(site: string): string { + const parsed = parseUnifiedSearchTargetSpec( + site.toLowerCase().startsWith("site:") ? site : `site:${site}`, + ); + if (parsed.site) return parsed.site; + throw new Error( + "Expected structured site target to normalize to site target.", + ); +} + function invalidSearchTargetResult(message: string): ToolResult { return errorResult( JSON.stringify({ diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 9ab9985f..c94c09f1 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -139,6 +139,33 @@ describe("searchAction", () => { consoleSpy.mockRestore(); }); + it("passes standalone site targets through unified search", async () => { + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); + const deps = createDeps({ + codeNavigationService: createMockCodeNavigationService({ search }), + }); + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchAction( + "router middleware", + { + in: ["site:expressjs.com"], + source: "docs", + }, + deps, + ); + + expect(search).toHaveBeenCalledWith( + expect.objectContaining({ + targets: [{ site: "site:expressjs.com" }], + sources: ["DOCS"], + }), + ); + consoleSpy.mockRestore(); + }); + it("preserves omitted repo refs for CLI discovery search targets", async () => { const search = mock((_: UnifiedSearchParams) => Promise.resolve(defaultUnifiedSearchOutcome),