From d05a2661bdc376202cf4a379e9ffadfe913f4085 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 5 May 2026 11:18:54 +0300 Subject: [PATCH 1/3] feat: improve search freshness follow-ups Preserves floating discovery targets while keeping read/list/grep targets exact, threads freshness metadata through search/status responses, and adds agent-ready follow-up commands for safer MCP usage. --- src/commands/search.test.ts | 30 ++- src/commands/search.ts | 2 +- src/services/client-update-required-error.ts | 17 +- src/services/code-navigation-service.test.ts | 79 +++++- src/services/code-navigation-service.ts | 251 +++++++++++++++++- .../package-intelligence-service.test.ts | 63 ++++- src/services/package-intelligence-service.ts | 18 ++ src/shared/code-navigation-target.ts | 6 +- src/shared/follow-up-command-text.ts | 18 +- src/shared/list-package-docs-text.ts | 2 +- src/shared/read-package-doc-text.ts | 2 +- src/shared/unified-search-request.test.ts | 23 +- src/shared/unified-search-request.ts | 8 - src/shared/unified-search-response.test.ts | 145 +++++++++- src/shared/unified-search-response.ts | 232 +++++++++++++++- src/shared/unified-search-status-text.ts | 20 +- src/shared/unified-search-target.ts | 36 ++- src/shared/unified-search-text.test.ts | 18 ++ src/shared/unified-search-text.ts | 42 +++ src/tools/code-navigation-shared.ts | 9 +- src/tools/list-files.test.ts | 12 + src/tools/list-package-docs.test.ts | 37 +++ src/tools/search-status.test.ts | 28 ++ src/tools/search.test.ts | 77 +++++- src/tools/search.ts | 111 ++++++-- 25 files changed, 1201 insertions(+), 85 deletions(-) diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index ebbab108..528edffa 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -54,7 +54,9 @@ describe("searchAction", () => { } it("calls unified search service with parsed targets and filters", async () => { - const search = mock(() => Promise.resolve(defaultUnifiedSearchOutcome)); + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); const deps = createDeps({ codeNavigationService: createMockCodeNavigationService({ search }), }); @@ -86,7 +88,9 @@ describe("searchAction", () => { }); it("passes repeatable --source values through as source filters", async () => { - const search = mock(() => Promise.resolve(defaultUnifiedSearchOutcome)); + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); const deps = createDeps({ codeNavigationService: createMockCodeNavigationService({ search }), }); @@ -109,6 +113,28 @@ describe("searchAction", () => { consoleSpy.mockRestore(); }); + it("preserves omitted repo refs for CLI discovery search targets", 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: ["https://github.com/expressjs/express"] }, + deps, + ); + + const call = search.mock.calls[0]?.[0]; + expect(call?.targets[0]).toEqual({ + repoUrl: "https://github.com/expressjs/express", + }); + consoleSpy.mockRestore(); + }); + it("uses the shared search default limit and preserves explicit limits", async () => { const search = mock((_: UnifiedSearchParams) => Promise.resolve(defaultUnifiedSearchOutcome), diff --git a/src/commands/search.ts b/src/commands/search.ts index c9812abd..58fa39c0 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -829,7 +829,7 @@ function formatUnifiedSearchMetadata( } if (entry.type === "repository_doc" && entry.locator.filePath) { - const ref = entry.locator.requestedRef ?? entry.locator.gitRef; + const ref = entry.locator.gitRef; lines.push( ` ${dim("file:", useColors)} ${entry.locator.filePath}${ref ? ` @ ${ref}` : ""}`, ); diff --git a/src/services/client-update-required-error.ts b/src/services/client-update-required-error.ts index 11095425..cba3ab9a 100644 --- a/src/services/client-update-required-error.ts +++ b/src/services/client-update-required-error.ts @@ -14,18 +14,17 @@ export class ClientUpdateRequiredError extends Error { } export function isClientUpdateRequiredGraphQLError(input: { - message: string; + message?: string; code?: string; }): boolean { - if (input.code === "CLIENT_UPDATE_REQUIRED") { - return true; - } - - const message = input.message; - if (!isGraphQLSchemaMismatchMessage(message)) { - return false; - } + return input.code === "CLIENT_UPDATE_REQUIRED"; +} +export function isGraphQLSchemaMismatchError(input: { + message: string; + code?: string; +}): boolean { + if (!isGraphQLSchemaMismatchMessage(input.message)) return false; return ( !input.code || input.code === "GRAPHQL_VALIDATION_FAILED" || diff --git a/src/services/code-navigation-service.test.ts b/src/services/code-navigation-service.test.ts index 314c1b52..97279b97 100644 --- a/src/services/code-navigation-service.test.ts +++ b/src/services/code-navigation-service.test.ts @@ -7,7 +7,6 @@ import { mock, spyOn, } from "bun:test"; -import { ClientUpdateRequiredError } from "./client-update-required-error.js"; import { CodeNavigationBackendError, CodeNavigationFileNotFoundError, @@ -668,6 +667,15 @@ describe("CodeNavigationServiceImpl", () => { expect(parsed.operation).toBe("search"); expect(parsed.graphqlQuery).toContain("query UnifiedSearch("); expect(parsed.graphqlQuery).toContain("filters: $filters"); + expect(parsed.graphqlQuery).toContain("requestedTargetLabel"); + expect(parsed.graphqlQuery).toContain("freshTargetLabel"); + expect(parsed.graphqlQuery).toContain("servedTargetLabel"); + expect(parsed.graphqlQuery).toContain("freshness"); + expect(parsed.graphqlQuery).toContain("requestedSources"); + expect(parsed.graphqlQuery).toContain("targetMode"); + expect(parsed.graphqlQuery).toContain("requestedTargets"); + expect(parsed.graphqlQuery).toContain("resolvedRequested"); + expect(parsed.graphqlQuery).toContain("requestedRefKind"); expect(parsed.variables).toEqual({ targets: [{ registry: "NPM", name: "express" }], query: "router middleware secret text", @@ -761,7 +769,7 @@ describe("CodeNavigationServiceImpl", () => { } }); - it("classifies GraphQL schema mismatch as ClientUpdateRequiredError", async () => { + it("classifies GraphQL schema mismatch as backend protocol error", async () => { mockFetch(() => Promise.resolve( new Response( @@ -787,7 +795,72 @@ describe("CodeNavigationServiceImpl", () => { targets: [{ registry: "NPM", packageName: "express" }], query: "middleware", }), - ).rejects.toBeInstanceOf(ClientUpdateRequiredError); + ).rejects.toMatchObject({ + name: "CodeNavigationBackendError", + message: expect.stringContaining("Backend protocol mismatch"), + }); + }); + + it("exposes GraphQL schema mismatch details when code-nav-wire debug is enabled", async () => { + process.env.GITHITS_DEBUG = "code-nav-wire"; + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + errors: [ + { + message: 'Cannot query field "search" on type "Query".', + extensions: { code: "GRAPHQL_VALIDATION_FAILED" }, + }, + ], + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + + await expect( + service.search({ + targets: [{ registry: "NPM", packageName: "express" }], + query: "middleware", + }), + ).rejects.toMatchObject({ + name: "CodeNavigationBackendError", + message: 'Cannot query field "search" on type "Query".', + }); + }); + + it("honors explicit backend CLIENT_UPDATE_REQUIRED errors", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + errors: [ + { + message: "Client version is no longer supported.", + extensions: { code: "CLIENT_UPDATE_REQUIRED" }, + }, + ], + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + + await expect( + service.search({ + targets: [{ registry: "NPM", packageName: "express" }], + query: "middleware", + }), + ).rejects.toMatchObject({ name: "ClientUpdateRequiredError" }); }); it("sends grepRepo variables with the correct shape", async () => { diff --git a/src/services/code-navigation-service.ts b/src/services/code-navigation-service.ts index 11b0c45f..c26acb3e 100644 --- a/src/services/code-navigation-service.ts +++ b/src/services/code-navigation-service.ts @@ -9,6 +9,7 @@ import type { PkgseerRegistry } from "../shared/pkgseer-registry.js"; import { ClientUpdateRequiredError, isClientUpdateRequiredGraphQLError, + isGraphQLSchemaMismatchError, } from "./client-update-required-error.js"; import { executeWithTokenRefresh } from "./execute-with-token-refresh.js"; import { AuthenticationError } from "./githits-service.js"; @@ -116,6 +117,26 @@ export type UnifiedSearchSessionStatus = | "TIMEOUT" | "FAILED"; +export type CodeIndexState = + | "CURRENT" + | "INDEXED" + | "INDEXING" + | "STALE" + | "FAILED" + | "MISSING" + | string; + +export type DiscoveryRequestedRefKind = + | "OMITTED_VERSION" + | "LATEST_VERSION" + | "EXACT_VERSION" + | "DEFAULT_BRANCH" + | "HEAD" + | "BRANCH" + | "SHA"; + +export type DiscoveryTargetMode = "PACKAGES" | "REPO" | "MIXED"; + export interface UnifiedSearchFilters { fileIntent?: FileIntent; kind?: SymbolKind; @@ -168,6 +189,10 @@ export interface UnifiedSearchHit { summary?: Array; }; locator: UnifiedSearchLocator; + requestedTargetLabel?: string; + freshTargetLabel?: string; + servedTargetLabel?: string; + freshness?: CodeIndexState; } export interface UnifiedSearchPageInfo { @@ -180,8 +205,11 @@ export interface UnifiedSearchPageInfo { export interface UnifiedSearchSourceStatus { source: UnifiedSearchSource; targetLabel: string; + requestedTargetLabel?: string; + freshTargetLabel?: string; + servedTargetLabel?: string; indexingStatus?: string; - codeIndexState?: string; + codeIndexState?: CodeIndexState; resultCount?: number; appliedFilters: string[]; ignoredFilters: string[]; @@ -192,6 +220,23 @@ export interface UnifiedSearchSourceStatus { note?: string; } +export interface UnifiedSearchProgressTarget { + requested?: string; + resolvedRequested?: string; + served?: string; + freshness?: CodeIndexState; + indexingRef?: string; + requestedRefKind?: DiscoveryRequestedRefKind; +} + +export interface UnifiedSearchRequestedTarget { + registry?: CodeNavigationRegistry; + name?: string; + version?: string; + repoUrl?: string; + gitRef?: string; +} + export interface UnifiedSearchResult { query: string; queryWarnings: string[]; @@ -211,6 +256,13 @@ export interface UnifiedSearchProgress { query: string; queryWarnings: string[]; sources: UnifiedSearchSource[]; + requestedSources?: UnifiedSearchSource[]; + targetMode?: DiscoveryTargetMode; + requestedTargets?: UnifiedSearchRequestedTarget[]; + filters?: UnifiedSearchFilters; + limit?: number; + offset?: number; + targets?: UnifiedSearchProgressTarget[]; expiresAt?: string; } @@ -558,6 +610,10 @@ query UnifiedSearch( id resultType targetLabel + requestedTargetLabel + freshTargetLabel + servedTargetLabel + freshness title summary score @@ -596,6 +652,9 @@ query UnifiedSearch( sourceStatus { source targetLabel + requestedTargetLabel + freshTargetLabel + servedTargetLabel indexingStatus codeIndexState resultCount @@ -617,6 +676,32 @@ query UnifiedSearch( query queryWarnings sources + requestedSources + targetMode + requestedTargets { + registry + name + version + repoUrl + gitRef + } + filters { + fileIntent + kind + category + publicOnly + pathPrefix + } + limit + offset + targets { + requested + resolvedRequested + served + freshness + indexingRef + requestedRefKind + } expiresAt } } @@ -633,6 +718,32 @@ query UnifiedSearchStatus($searchRef: String!, $includeResults: Boolean!) { query queryWarnings sources + requestedSources + targetMode + requestedTargets { + registry + name + version + repoUrl + gitRef + } + filters { + fileIntent + kind + category + publicOnly + pathPrefix + } + limit + offset + targets { + requested + resolvedRequested + served + freshness + indexingRef + requestedRefKind + } expiresAt results { query @@ -642,6 +753,10 @@ query UnifiedSearchStatus($searchRef: String!, $includeResults: Boolean!) { id resultType targetLabel + requestedTargetLabel + freshTargetLabel + servedTargetLabel + freshness title summary score @@ -680,6 +795,9 @@ query UnifiedSearchStatus($searchRef: String!, $includeResults: Boolean!) { sourceStatus { source targetLabel + requestedTargetLabel + freshTargetLabel + servedTargetLabel indexingStatus codeIndexState resultCount @@ -806,6 +924,10 @@ const unifiedSearchHitSchema = z.object({ id: z.string(), resultType: unifiedSearchResultTypeSchema, targetLabel: z.string(), + requestedTargetLabel: z.string().nullable().optional(), + freshTargetLabel: z.string().nullable().optional(), + servedTargetLabel: z.string().nullable().optional(), + freshness: z.string().nullable().optional(), title: z.string().nullable().optional(), summary: z.string().nullable().optional(), score: z.number().nullable().optional(), @@ -835,6 +957,9 @@ const unifiedSearchPageInfoSchema = z.object({ const unifiedSearchSourceStatusSchema = z.object({ source: unifiedSearchSourceSchema, targetLabel: z.string(), + requestedTargetLabel: z.string().nullable().optional(), + freshTargetLabel: z.string().nullable().optional(), + servedTargetLabel: z.string().nullable().optional(), indexingStatus: z.string().nullable().optional(), codeIndexState: z.string().nullable().optional(), resultCount: z.number().int().nullable().optional(), @@ -866,6 +991,34 @@ const unifiedSearchSessionStatusSchema = z.enum([ "FAILED", ]); +const unifiedSearchFiltersSchema = z + .object({ + fileIntent: z.string().nullable().optional(), + kind: z.string().nullable().optional(), + category: z.string().nullable().optional(), + publicOnly: z.boolean().nullable().optional(), + pathPrefix: z.string().nullable().optional(), + }) + .nullable() + .optional(); + +const unifiedSearchProgressTargetSchema = z.object({ + requested: z.string().nullable().optional(), + resolvedRequested: z.string().nullable().optional(), + served: z.string().nullable().optional(), + freshness: z.string().nullable().optional(), + indexingRef: z.string().nullable().optional(), + requestedRefKind: z.string().nullable().optional(), +}); + +const unifiedSearchRequestedTargetSchema = z.object({ + registry: z.string().nullable().optional(), + name: z.string().nullable().optional(), + version: z.string().nullable().optional(), + repoUrl: z.string().nullable().optional(), + gitRef: z.string().nullable().optional(), +}); + const unifiedSearchProgressSchema = z.object({ searchRef: z.string(), status: unifiedSearchSessionStatusSchema, @@ -875,6 +1028,16 @@ const unifiedSearchProgressSchema = z.object({ query: z.string(), queryWarnings: z.array(z.string()), sources: z.array(unifiedSearchSourceSchema), + requestedSources: z.array(unifiedSearchSourceSchema).nullable().optional(), + targetMode: z.string().nullable().optional(), + requestedTargets: z + .array(unifiedSearchRequestedTargetSchema) + .nullable() + .optional(), + filters: unifiedSearchFiltersSchema, + limit: z.number().int().nullable().optional(), + offset: z.number().int().nullable().optional(), + targets: z.array(unifiedSearchProgressTargetSchema).nullable().optional(), expiresAt: z.string().nullable().optional(), results: unifiedSearchResultSchema.nullable().optional(), }); @@ -1510,6 +1673,22 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { return new ClientUpdateRequiredError(); } + if (isGraphQLSchemaMismatchError({ message, code })) { + const sanitized = + "Backend protocol mismatch. Your CLI may be newer than the server, or the server may require a newer CLI. Run `githits update-check` to verify your installed version. Set GITHITS_DEBUG=code-nav-wire to inspect GraphQL details during local development."; + debugLog("code-nav", { + event: "graphql-schema-mismatch", + code: code ?? "omitted", + message, + }); + return new CodeNavigationBackendError( + isDebugAreaEnabled("code-nav-wire") ? message : sanitized, + undefined, + code, + retryable, + ); + } + // Direct dispatch on extensions.code — the April 2026 backend // contract populates this on every error. Fall back to message // heuristics below for older backend builds that haven't @@ -1694,6 +1873,10 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { id: entry.id, resultType: entry.resultType, targetLabel: entry.targetLabel, + requestedTargetLabel: entry.requestedTargetLabel ?? undefined, + freshTargetLabel: entry.freshTargetLabel ?? undefined, + servedTargetLabel: entry.servedTargetLabel ?? undefined, + freshness: entry.freshness ?? undefined, title: entry.title ?? undefined, summary: entry.summary ?? undefined, score: entry.score ?? undefined, @@ -1734,6 +1917,9 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { sourceStatus: result.sourceStatus.map((entry) => ({ source: entry.source, targetLabel: entry.targetLabel, + requestedTargetLabel: entry.requestedTargetLabel ?? undefined, + freshTargetLabel: entry.freshTargetLabel ?? undefined, + servedTargetLabel: entry.servedTargetLabel ?? undefined, indexingStatus: entry.indexingStatus ?? undefined, codeIndexState: entry.codeIndexState ?? undefined, resultCount: entry.resultCount ?? undefined, @@ -1760,6 +1946,28 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { query: progress.query, queryWarnings: progress.queryWarnings, sources: progress.sources, + requestedSources: progress.requestedSources ?? undefined, + targetMode: normaliseTargetMode(progress.targetMode), + requestedTargets: progress.requestedTargets?.map((target) => ({ + registry: target.registry + ? (target.registry as CodeNavigationRegistry) + : undefined, + name: target.name ?? undefined, + version: target.version ?? undefined, + repoUrl: target.repoUrl ?? undefined, + gitRef: target.gitRef ?? undefined, + })), + filters: normaliseProgressFilters(progress.filters), + limit: progress.limit ?? undefined, + offset: progress.offset ?? undefined, + targets: progress.targets?.map((target) => ({ + requested: target.requested ?? undefined, + resolvedRequested: target.resolvedRequested ?? undefined, + served: target.served ?? undefined, + freshness: target.freshness ?? undefined, + indexingRef: target.indexingRef ?? undefined, + requestedRefKind: normaliseRequestedRefKind(target.requestedRefKind), + })), expiresAt: progress.expiresAt ?? undefined, }; } @@ -2202,6 +2410,47 @@ function isAuthMessage(message: string): boolean { ); } +function normaliseTargetMode( + value: string | null | undefined, +): DiscoveryTargetMode | undefined { + if (value === "PACKAGES" || value === "REPO" || value === "MIXED") { + return value; + } + return undefined; +} + +function normaliseRequestedRefKind( + value: string | null | undefined, +): DiscoveryRequestedRefKind | undefined { + switch (value) { + case "OMITTED_VERSION": + case "LATEST_VERSION": + case "EXACT_VERSION": + case "DEFAULT_BRANCH": + case "HEAD": + case "BRANCH": + case "SHA": + return value; + default: + return undefined; + } +} + +function normaliseProgressFilters( + filters: z.infer, +): UnifiedSearchFilters | undefined { + if (!filters) return undefined; + const out: UnifiedSearchFilters = {}; + if (filters.fileIntent) out.fileIntent = filters.fileIntent as FileIntent; + if (filters.kind) out.kind = filters.kind as SymbolKind; + if (filters.category) out.category = filters.category as SymbolCategory; + if (typeof filters.publicOnly === "boolean") { + out.publicOnly = filters.publicOnly; + } + if (filters.pathPrefix) out.pathPrefix = filters.pathPrefix; + return Object.keys(out).length > 0 ? out : undefined; +} + /** * Heuristic fallback for NOT_FOUND detection when the backend does not * populate `extensions.code` on GraphQL errors. See backend request B8 diff --git a/src/services/package-intelligence-service.test.ts b/src/services/package-intelligence-service.test.ts index 5fc983e5..9150890f 100644 --- a/src/services/package-intelligence-service.test.ts +++ b/src/services/package-intelligence-service.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import { ClientUpdateRequiredError } from "./client-update-required-error.js"; import { AuthenticationError } from "./githits-service.js"; import { MalformedPackageIntelligenceResponseError, @@ -83,6 +82,7 @@ describe("PackageIntelligenceServiceImpl", () => { const ENDPOINT = "https://pkgseer.dev"; let originalFetch: typeof globalThis.fetch; + const originalDebug = process.env.GITHITS_DEBUG; beforeEach(() => { originalFetch = globalThis.fetch; @@ -90,6 +90,8 @@ describe("PackageIntelligenceServiceImpl", () => { afterEach(() => { globalThis.fetch = originalFetch; + if (originalDebug === undefined) delete process.env.GITHITS_DEBUG; + else process.env.GITHITS_DEBUG = originalDebug; }); it("maps a happy-path response to PackageSummary", async () => { @@ -426,7 +428,7 @@ describe("PackageIntelligenceServiceImpl", () => { ).rejects.toBeInstanceOf(PackageIntelligenceValidationError); }); - it("classifies GraphQL schema mismatch as ClientUpdateRequiredError", async () => { + it("classifies GraphQL schema mismatch as backend protocol error", async () => { const fetchFn = mock(() => Promise.resolve( jsonResponse({ @@ -447,7 +449,62 @@ describe("PackageIntelligenceServiceImpl", () => { await expect( service.packageSummary({ registry: "NPM", packageName: "x" }), - ).rejects.toBeInstanceOf(ClientUpdateRequiredError); + ).rejects.toMatchObject({ + name: "PackageIntelligenceBackendError", + message: expect.stringContaining("Backend protocol mismatch"), + }); + }); + + it("exposes GraphQL schema mismatch details when pkg-graphql debug is enabled", async () => { + process.env.GITHITS_DEBUG = "pkg-graphql"; + const fetchFn = mock(() => + Promise.resolve( + jsonResponse({ + errors: [ + { + message: 'Cannot query field "packageSummary" on type "Query".', + extensions: { code: "GRAPHQL_VALIDATION_FAILED" }, + }, + ], + }), + ), + ); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + await expect( + service.packageSummary({ registry: "NPM", packageName: "x" }), + ).rejects.toMatchObject({ + name: "PackageIntelligenceBackendError", + message: 'Cannot query field "packageSummary" on type "Query".', + }); + }); + + it("honors explicit backend CLIENT_UPDATE_REQUIRED errors", async () => { + const fetchFn = mock(() => + Promise.resolve( + jsonResponse({ + errors: [ + { + message: "Client version is no longer supported.", + extensions: { code: "CLIENT_UPDATE_REQUIRED" }, + }, + ], + }), + ), + ); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + await expect( + service.packageSummary({ registry: "NPM", packageName: "x" }), + ).rejects.toMatchObject({ name: "ClientUpdateRequiredError" }); }); it("classifies 5xx plain-text body via parseDetail as PackageIntelligenceBackendError", async () => { diff --git a/src/services/package-intelligence-service.ts b/src/services/package-intelligence-service.ts index 798eafcf..1020cc56 100644 --- a/src/services/package-intelligence-service.ts +++ b/src/services/package-intelligence-service.ts @@ -17,6 +17,7 @@ */ import { z } from "zod"; +import { debugLog, isDebugAreaEnabled } from "../shared/debug-log.js"; import { type PkgseerGraphqlResponse, PkgseerTransportError, @@ -27,6 +28,7 @@ import { withTelemetrySpan } from "../shared/telemetry.js"; import { ClientUpdateRequiredError, isClientUpdateRequiredGraphQLError, + isGraphQLSchemaMismatchError, } from "./client-update-required-error.js"; import { executeWithTokenRefresh } from "./execute-with-token-refresh.js"; import { AuthenticationError } from "./githits-service.js"; @@ -1405,6 +1407,22 @@ export class PackageIntelligenceServiceImpl return new ClientUpdateRequiredError(); } + if (isGraphQLSchemaMismatchError({ message, code })) { + const sanitized = + "Backend protocol mismatch. Your CLI may be newer than the server, or the server may require a newer CLI. Run `githits update-check` to verify your installed version. Set GITHITS_DEBUG=pkg-graphql to inspect GraphQL details during local development."; + debugLog("pkg-graphql", { + event: "graphql-schema-mismatch", + code: code ?? "omitted", + message, + }); + return new PackageIntelligenceBackendError( + isDebugAreaEnabled("pkg-graphql") ? message : sanitized, + undefined, + code, + retryable, + ); + } + switch (code) { case "NOT_FOUND": case "PACKAGE_NOT_FOUND": diff --git a/src/shared/code-navigation-target.ts b/src/shared/code-navigation-target.ts index 278c9abf..d7e85c9a 100644 --- a/src/shared/code-navigation-target.ts +++ b/src/shared/code-navigation-target.ts @@ -8,6 +8,8 @@ import { InvalidArgumentError, parsePackageSpec } from "./package-spec.js"; * Package targets use the shared package spec grammar, e.g. * `npm:react@18.2.0`. Repository targets are full URLs with an optional * `#gitRef` suffix, e.g. `https://github.com/facebook/react#HEAD`. + * Exact code-navigation tools require repository refs because their + * backend endpoints operate on a concrete repository identity. */ export function parseCodeNavigationTargetSpec( spec: string, @@ -32,7 +34,9 @@ export function parseCodeNavigationTargetSpec( function parseRepoTarget(spec: string): CodeNavigationTarget { const hashIndex = spec.lastIndexOf("#"); if (hashIndex === -1) { - return { repoUrl: spec, gitRef: "HEAD" }; + throw new InvalidArgumentError( + "Repository target must include #gitRef for exact code navigation.", + ); } const repoUrl = spec.slice(0, hashIndex); diff --git a/src/shared/follow-up-command-text.ts b/src/shared/follow-up-command-text.ts index a30d934c..f12f21e4 100644 --- a/src/shared/follow-up-command-text.ts +++ b/src/shared/follow-up-command-text.ts @@ -9,6 +9,7 @@ interface CodeReadCommandInput { filePath?: string; startLine?: number; endLine?: number; + preferPackageTarget?: boolean; } export function buildSearchHitFollowUpCommand( @@ -28,6 +29,7 @@ export function buildSearchHitFollowUpCommand( filePath: loc.filePath, startLine: loc.startLine, endLine: loc.endLine, + preferPackageTarget: isPackageTarget(hit), }); } if (hit.type === "repository_code" || hit.type === "repository_symbol") { @@ -60,8 +62,12 @@ export function buildCodeReadCommand(input: CodeReadCommandInput): string { } function buildTargetSpec(input: CodeReadCommandInput): string | undefined { + if (input.preferPackageTarget && input.registry && input.packageName) { + return `${input.registry}:${input.packageName}${input.version ? `@${input.version}` : ""}`; + } if (input.repoUrl) { - return `${input.repoUrl}#${input.gitRef ?? "HEAD"}`; + if (!input.gitRef) return undefined; + return `${input.repoUrl}#${input.gitRef}`; } if (input.registry && input.packageName) { return `${input.registry}:${input.packageName}${input.version ? `@${input.version}` : ""}`; @@ -69,6 +75,16 @@ function buildTargetSpec(input: CodeReadCommandInput): string | undefined { return undefined; } +function isPackageTarget(hit: UnifiedSearchHitPayload): boolean { + const registry = hit.locator.registry; + const packageName = hit.locator.packageName; + return Boolean( + registry && + packageName && + hit.target.startsWith(`${registry}:${packageName}`), + ); +} + function appendRange( parts: string[], startLine: number | undefined, diff --git a/src/shared/list-package-docs-text.ts b/src/shared/list-package-docs-text.ts index 60199723..2056eedf 100644 --- a/src/shared/list-package-docs-text.ts +++ b/src/shared/list-package-docs-text.ts @@ -32,7 +32,7 @@ export function renderListPackageDocsText( lines.push( ` ${buildCodeReadCommand({ repoUrl: page.repoUrl, - gitRef: page.requestedRef ?? page.gitRef, + gitRef: page.gitRef, filePath: page.filePath, startLine: 1, endLine: 150, diff --git a/src/shared/read-package-doc-text.ts b/src/shared/read-package-doc-text.ts index 240634e2..44861fbf 100644 --- a/src/shared/read-package-doc-text.ts +++ b/src/shared/read-package-doc-text.ts @@ -9,7 +9,7 @@ export function renderReadPackageDocText( lines.push(buildHeader(envelope)); if (envelope.sourceUrl) lines.push(`source: ${envelope.sourceUrl}`); if (envelope.filePath) { - const ref = envelope.requestedRef ?? envelope.gitRef; + const ref = envelope.gitRef; lines.push(`file: ${envelope.filePath}${ref ? ` @ ${ref}` : ""}`); } lines.push(""); diff --git a/src/shared/unified-search-request.test.ts b/src/shared/unified-search-request.test.ts index 2103531a..4e997f42 100644 --- a/src/shared/unified-search-request.test.ts +++ b/src/shared/unified-search-request.test.ts @@ -122,15 +122,18 @@ describe("buildUnifiedSearchParams", () => { ]); }); - it("rejects mixed package and repo targets", () => { - expect(() => - buildUnifiedSearchParams({ - targets: [ - { registry: "NPM", packageName: "express" }, - { repoUrl: "https://github.com/expressjs/express", gitRef: "main" }, - ], - query: "router", - }), - ).toThrow("Do not mix package-scoped and repo-scoped targets"); + it("accepts mixed package and repo targets", () => { + const built = buildUnifiedSearchParams({ + targets: [ + { registry: "NPM", packageName: "express" }, + { repoUrl: "https://github.com/expressjs/express", gitRef: "main" }, + ], + query: "router", + }); + + expect(built.params.targets).toEqual([ + { registry: "NPM", packageName: "express" }, + { repoUrl: "https://github.com/expressjs/express", gitRef: "main" }, + ]); }); }); diff --git a/src/shared/unified-search-request.ts b/src/shared/unified-search-request.ts index 5fdd3bee..67d88bc9 100644 --- a/src/shared/unified-search-request.ts +++ b/src/shared/unified-search-request.ts @@ -100,14 +100,6 @@ function resolveTargets( deduped.push(entry); } - const hasPackageTarget = deduped.some((entry) => entry.packageName); - const hasRepoTarget = deduped.some((entry) => entry.repoUrl); - if (hasPackageTarget && hasRepoTarget) { - throw new InvalidArgumentError( - "Do not mix package-scoped and repo-scoped targets in one search.", - ); - } - return deduped; } diff --git a/src/shared/unified-search-response.test.ts b/src/shared/unified-search-response.test.ts index f701b797..34bbbdad 100644 --- a/src/shared/unified-search-response.test.ts +++ b/src/shared/unified-search-response.test.ts @@ -30,7 +30,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { expect(payload.completed).toBe(true); expect(payload.results.length).toBe(1); - expect(payload.results[0]).toEqual({ + expect(payload.results[0]).toMatchObject({ type: "repository_code", target: "npm:express@4.18.2", title: "router middleware", @@ -44,6 +44,9 @@ describe("buildUnifiedSearchSuccessPayload", () => { language: "javascript", }), }); + expect(payload.results[0]?.followUp).toBe( + 'code_read target="npm:express@4.18.2" path="lib/router/index.js" start_line=42 end_line=57', + ); expect(payload.results[0]).not.toHaveProperty("score"); }); @@ -95,6 +98,8 @@ describe("buildUnifiedSearchSuccessPayload", () => { targetsReady: 0, targetsTotal: 1, elapsedMs: 200, + query: "router middleware", + next: 'search_status search_ref="search-ref-123"', }, }); }); @@ -134,6 +139,113 @@ describe("buildUnifiedSearchSuccessPayload", () => { expect(payload.results.length).toBe(1); expect(payload.results[0]?.target).toBe("npm:express@4.18.2"); }); + + it("projects stale hit freshness into compact fields and warnings", () => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + const outcome: UnifiedSearchOutcome = { + ...defaultUnifiedSearchOutcome, + result: { + ...defaultUnifiedSearchOutcome.result, + results: [ + { + ...defaultUnifiedSearchOutcome.result.results[0]!, + requestedTargetLabel: "npm:express latest", + freshTargetLabel: "npm:express@5.2.1", + servedTargetLabel: "npm:express@5.1.0", + freshness: "STALE", + }, + ], + }, + }; + + const payload = buildUnifiedSearchSuccessPayload( + params, + "router middleware", + "router middleware", + outcome, + ); + + expect(payload.results[0]).toMatchObject({ + requestedTarget: "npm:express latest", + freshTarget: "npm:express@5.2.1", + servedTarget: "npm:express@5.1.0", + freshness: "STALE", + }); + expect(payload.warnings).toContain( + "requested npm:express latest; served stale npm:express@5.1.0 while npm:express@5.2.1 indexes.", + ); + }); + + it("omits non-actionable current freshness metadata from hits", () => { + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + const outcome: UnifiedSearchOutcome = { + ...defaultUnifiedSearchOutcome, + result: { + ...defaultUnifiedSearchOutcome.result, + results: [ + { + ...defaultUnifiedSearchOutcome.result.results[0]!, + requestedTargetLabel: "expressjs/express", + freshTargetLabel: "expressjs/express@master", + servedTargetLabel: "expressjs/express@master", + freshness: "CURRENT", + }, + ], + }, + }; + + const payload = buildUnifiedSearchSuccessPayload( + params, + "router middleware", + "router middleware", + outcome, + ); + + expect(payload.results[0]).not.toHaveProperty("requestedTarget"); + expect(payload.results[0]).not.toHaveProperty("freshTarget"); + expect(payload.results[0]).not.toHaveProperty("servedTarget"); + expect(payload.results[0]).not.toHaveProperty("freshness"); + expect(payload.warnings).toBeUndefined(); + }); + + it("projects progress freshness warnings without result hits", () => { + const payload = buildUnifiedSearchSuccessPayload( + params, + "router middleware", + "router middleware", + { + state: "incomplete", + completed: false, + searchRef: "search-ref-123", + progress: { + searchRef: "search-ref-123", + status: "INDEXING", + targetsTotal: 1, + targetsReady: 0, + elapsedMs: 200, + query: "router middleware", + queryWarnings: [], + sources: ["CODE"], + targets: [ + { + requested: "https://github.com/foo/bar default branch", + resolvedRequested: "main@def456", + served: "main@abc123", + freshness: "STALE", + }, + ], + }, + }, + ); + + expect(payload.warnings).toContain( + "requested https://github.com/foo/bar default branch; served stale main@abc123 while main@def456 indexes.", + ); + }); }); describe("buildSourceStatusWarnings — sourceStatus → warnings promotion", () => { @@ -200,6 +312,35 @@ describe("buildSourceStatusWarnings — sourceStatus → warnings promotion", () expect(warnings[0]).toContain("docs"); expect(warnings[1]).toContain("code"); }); + + it("does not warn for stale source status without label divergence", () => { + expect( + buildSourceStatusWarnings([ + { + source: "code", + targetLabel: "npm:express@5.1.0", + codeIndexState: "STALE", + }, + ]), + ).toEqual([]); + }); + + it("warns for stale source status when labels diverge", () => { + expect( + buildSourceStatusWarnings([ + { + source: "code", + targetLabel: "npm:express@5.1.0", + requestedTarget: "npm:express latest", + freshTarget: "npm:express@5.2.1", + servedTarget: "npm:express@5.1.0", + codeIndexState: "STALE", + }, + ]), + ).toEqual([ + "requested npm:express latest; served stale npm:express@5.1.0 while npm:express@5.2.1 indexes.", + ]); + }); }); describe("buildUnifiedSearchSuccessPayload — sourceStatus warnings on completed payloads", () => { @@ -365,6 +506,8 @@ describe("buildUnifiedSearchStatusPayload", () => { targetsReady: 0, targetsTotal: 1, elapsedMs: 200, + query: "router middleware", + next: 'search_status search_ref="search-ref-123"', }, }); }); diff --git a/src/shared/unified-search-response.ts b/src/shared/unified-search-response.ts index d3adf160..09da1ba0 100644 --- a/src/shared/unified-search-response.ts +++ b/src/shared/unified-search-response.ts @@ -9,6 +9,7 @@ import type { import { MalformedCodeNavigationResponseError } from "../services/index.js"; import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; import { mapCodeNavigationError } from "./code-navigation-error-map.js"; +import { buildSearchHitFollowUpCommand } from "./follow-up-command-text.js"; import { DEFAULT_UNIFIED_SEARCH_LIMIT } from "./unified-search-request.js"; /** @@ -46,9 +47,14 @@ export interface UnifiedSearchHighlightsPayload { export interface UnifiedSearchHitPayload { type: string; target: string; + requestedTarget?: string; + freshTarget?: string; + servedTarget?: string; + freshness?: string; title?: string; summary?: string; highlights?: UnifiedSearchHighlightsPayload; + followUp?: string; locator: { registry?: string; packageName?: string; @@ -74,12 +80,37 @@ export interface UnifiedSearchProgressPayload { targetsReady: number; targetsTotal: number; elapsedMs: number; + query?: string; + requestedSources?: string[]; + targetMode?: string; + requestedTargets?: Array<{ + registry?: string; + name?: string; + version?: string; + repoUrl?: string; + gitRef?: string; + }>; + filters?: UnifiedSearchQueryEcho["filters"]; + limit?: number; + offset?: number; + targets?: Array<{ + requested?: string; + resolvedRequested?: string; + served?: string; + freshness?: string; + indexingRef?: string; + requestedRefKind?: string; + }>; expiresAt?: string; + next?: string; } export interface UnifiedSearchSourceStatusPayload { source: string; targetLabel: string; + requestedTarget?: string; + freshTarget?: string; + servedTarget?: string; indexingStatus?: string; codeIndexState?: string; resultCount?: number; @@ -147,6 +178,7 @@ export interface UnifiedSearchStatusIncompletePayload { searchRef: string; progress?: UnifiedSearchProgressPayload; result?: UnifiedSearchStatusResultPayload; + warnings?: string[]; } export function buildUnifiedSearchSuccessPayload( @@ -161,6 +193,7 @@ export function buildUnifiedSearchSuccessPayload( : (outcome.result?.queryWarnings ?? outcome.progress?.queryWarnings ?? []); + const progress = compactProgress(outcome.progress); const query = buildQueryEcho(params, rawQuery, compiledQuery, warnings); if (outcome.state === "incomplete") { @@ -175,11 +208,15 @@ export function buildUnifiedSearchSuccessPayload( if (result?.page.hasMore === true) { payload.nextOffset = result.page.offset + result.page.returned; } - const progress = compactProgress(outcome.progress); if (progress) payload.progress = progress; const sourceStatus = compactSourceStatus(result?.sourceStatus); if (sourceStatus) payload.sourceStatus = sourceStatus; - const combinedWarnings = combineWarnings(warnings, sourceStatus); + const combinedWarnings = combineWarnings( + warnings, + sourceStatus, + payload.results, + progress, + ); if (combinedWarnings.length > 0) payload.warnings = combinedWarnings; return payload; } @@ -197,7 +234,12 @@ export function buildUnifiedSearchSuccessPayload( if (outcome.searchRef) completed.searchRef = outcome.searchRef; const sourceStatus = compactSourceStatus(outcome.result.sourceStatus); if (sourceStatus) completed.sourceStatus = sourceStatus; - const combinedWarnings = combineWarnings(warnings, sourceStatus); + const combinedWarnings = combineWarnings( + warnings, + sourceStatus, + completed.results, + progress, + ); if (combinedWarnings.length > 0) completed.warnings = combinedWarnings; return completed; } @@ -217,9 +259,13 @@ export function buildUnifiedSearchSuccessPayload( function combineWarnings( parserWarnings: readonly string[], sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, + hits: UnifiedSearchHitPayload[] = [], + progress?: UnifiedSearchProgressPayload, ): string[] { const out: string[] = []; if (parserWarnings.length > 0) out.push(...parserWarnings); + out.push(...buildHitFreshnessWarnings(hits)); + out.push(...buildProgressFreshnessWarnings(progress)); out.push(...buildSourceStatusWarnings(sourceStatus)); return out; } @@ -251,6 +297,8 @@ export function buildUnifiedSearchStatusPayload( }; const progress = compactProgress(outcome.progress); if (progress) payload.progress = progress; + const progressWarnings = buildProgressFreshnessWarnings(progress); + if (progressWarnings.length > 0) payload.warnings = progressWarnings; if (outcome.result) { payload.result = buildUnifiedSearchStatusResultPayload(outcome.result); } @@ -343,10 +391,18 @@ function buildHitPayload(hit: UnifiedSearchHit): UnifiedSearchHitPayload { target: hit.targetLabel, locator: buildLocatorPayload(hit), }; + appendFreshness(payload, { + requestedTargetLabel: hit.requestedTargetLabel, + freshTargetLabel: hit.freshTargetLabel, + servedTargetLabel: hit.servedTargetLabel, + freshness: hit.freshness, + }); if (hit.title) payload.title = hit.title; if (hit.summary) payload.summary = hit.summary; const highlights = buildHighlights(hit.highlights); if (highlights) payload.highlights = highlights; + const followUp = buildSearchHitFollowUpCommand(payload); + if (followUp) payload.followUp = followUp; return payload; } @@ -403,10 +459,91 @@ function compactProgress( targetsTotal: progress.targetsTotal, elapsedMs: progress.elapsedMs, }; + if (progress.query) payload.query = progress.query; + if (progress.requestedSources?.length) { + payload.requestedSources = progress.requestedSources.map((entry) => + entry.toLowerCase(), + ); + } + if (progress.targetMode) payload.targetMode = progress.targetMode; + if (progress.requestedTargets?.length) { + payload.requestedTargets = progress.requestedTargets; + } + if (progress.filters) payload.filters = buildFilterEcho(progress.filters); + if (typeof progress.limit === "number") payload.limit = progress.limit; + if (typeof progress.offset === "number") payload.offset = progress.offset; + const targets = progress.targets?.map(compactProgressTarget).filter(Boolean); + if (targets?.length) { + payload.targets = targets as NonNullable< + UnifiedSearchProgressPayload["targets"] + >; + } if (progress.expiresAt) payload.expiresAt = progress.expiresAt; + payload.next = `search_status search_ref=${JSON.stringify(progress.searchRef)}`; return payload; } +function appendFreshness( + payload: { + requestedTarget?: string; + freshTarget?: string; + servedTarget?: string; + freshness?: string; + }, + source: { + requestedTargetLabel?: string; + freshTargetLabel?: string; + servedTargetLabel?: string; + freshness?: string; + }, +): void { + if ( + !isTrustRelevantFreshness(source.freshness) || + !labelsDiverge({ + requestedTarget: source.requestedTargetLabel, + freshTarget: source.freshTargetLabel, + servedTarget: source.servedTargetLabel, + }) + ) { + return; + } + if (source.requestedTargetLabel) + payload.requestedTarget = source.requestedTargetLabel; + if (source.freshTargetLabel) payload.freshTarget = source.freshTargetLabel; + if (source.servedTargetLabel) payload.servedTarget = source.servedTargetLabel; + if (source.freshness) payload.freshness = source.freshness; +} + +function compactProgressTarget( + target: NonNullable[number], +): NonNullable[number] | undefined { + const payload: NonNullable[number] = + {}; + if (target.requested) payload.requested = target.requested; + if (target.resolvedRequested) + payload.resolvedRequested = target.resolvedRequested; + if (target.served) payload.served = target.served; + if (target.freshness) payload.freshness = target.freshness; + if (target.indexingRef) payload.indexingRef = target.indexingRef; + if (target.requestedRefKind) + payload.requestedRefKind = target.requestedRefKind; + return Object.keys(payload).length > 0 ? payload : undefined; +} + +function buildFilterEcho( + filters: NonNullable, +): UnifiedSearchQueryEcho["filters"] | undefined { + const echo: NonNullable = {}; + if (filters.kind) echo.kind = filters.kind.toLowerCase(); + if (filters.category) echo.category = filters.category.toLowerCase(); + if (filters.pathPrefix) echo.pathPrefix = filters.pathPrefix; + if (filters.fileIntent) echo.fileIntent = filters.fileIntent.toLowerCase(); + if (typeof filters.publicOnly === "boolean") { + echo.publicOnly = filters.publicOnly; + } + return Object.keys(echo).length > 0 ? echo : undefined; +} + /** * Promote noteworthy `sourceStatus` entries into top-level human-readable * warning strings so callers see them without inspecting the nested @@ -433,10 +570,78 @@ export function buildSourceStatusWarnings( return warnings; } +function buildHitFreshnessWarnings(hits: UnifiedSearchHitPayload[]): string[] { + return hits + .map((hit) => + freshnessWarning({ + freshness: hit.freshness, + requestedTarget: hit.requestedTarget, + freshTarget: hit.freshTarget, + servedTarget: hit.servedTarget, + }), + ) + .filter((entry): entry is string => Boolean(entry)); +} + +function buildProgressFreshnessWarnings( + progress: UnifiedSearchProgressPayload | undefined, +): string[] { + return (progress?.targets ?? []) + .map((target) => + freshnessWarning({ + freshness: target.freshness, + requestedTarget: target.requested, + freshTarget: target.resolvedRequested, + servedTarget: target.served, + }), + ) + .filter((entry): entry is string => Boolean(entry)); +} + +function freshnessWarning(input: { + freshness?: string; + requestedTarget?: string; + freshTarget?: string; + servedTarget?: string; +}): string | undefined { + if (!isTrustRelevantFreshness(input.freshness)) return undefined; + if (!labelsDiverge(input)) return undefined; + const requested = input.requestedTarget ?? "requested target"; + const served = input.servedTarget ?? "served target"; + const fresh = input.freshTarget; + return fresh + ? `requested ${requested}; served stale ${served} while ${fresh} indexes.` + : `requested ${requested}; served stale ${served}.`; +} + +function isTrustRelevantFreshness(value: string | undefined): boolean { + return value === "STALE" || value === "INDEXING"; +} + +function labelsDiverge(input: { + requestedTarget?: string; + freshTarget?: string; + servedTarget?: string; +}): boolean { + const served = input.servedTarget; + if (!served) return false; + return Boolean( + (input.freshTarget && input.freshTarget !== served) || + (input.requestedTarget && input.requestedTarget !== served), + ); +} + function warningForEntry( entry: UnifiedSearchSourceStatusPayload, ): string | undefined { const reasons: string[] = []; + const freshness = freshnessWarning({ + freshness: entry.codeIndexState, + requestedTarget: entry.requestedTarget, + freshTarget: entry.freshTarget, + servedTarget: entry.servedTarget, + }); + if (freshness) return freshness; if (entry.incompatibleQueryFeatures?.length) { reasons.push( `incompatible query features [${entry.incompatibleQueryFeatures.join(", ")}]`, @@ -463,7 +668,9 @@ function warningForEntry( reasons.push(`indexing status ${entry.indexingStatus}`); } if (entry.codeIndexState) { - reasons.push(`code index state ${entry.codeIndexState}`); + if (entry.codeIndexState !== "STALE") { + reasons.push(`code index state ${entry.codeIndexState}`); + } } // Source/target prefix anchors the message so an agent reading // multi-source warnings can tell which target each refers to. @@ -498,6 +705,21 @@ function compactSourceStatusEntry( }; let interesting = false; + const staleDiverges = + entry.codeIndexState === "STALE" && + labelsDiverge({ + requestedTarget: entry.requestedTargetLabel, + freshTarget: entry.freshTargetLabel, + servedTarget: entry.servedTargetLabel, + }); + if (staleDiverges) { + payload.requestedTarget = entry.requestedTargetLabel; + payload.freshTarget = entry.freshTargetLabel; + payload.servedTarget = entry.servedTargetLabel; + payload.codeIndexState = entry.codeIndexState; + interesting = true; + } + // Suppress healthy lifecycle states. INDEXED means searchable; CURRENT // and STALE both have data agents can use (STALE = served from a slightly // older navpack while a reindex runs — we do not warn agents about it). @@ -508,7 +730,7 @@ function compactSourceStatusEntry( if ( entry.codeIndexState && entry.codeIndexState !== "CURRENT" && - entry.codeIndexState !== "STALE" + (entry.codeIndexState !== "STALE" || staleDiverges) ) { payload.codeIndexState = entry.codeIndexState; interesting = true; diff --git a/src/shared/unified-search-status-text.ts b/src/shared/unified-search-status-text.ts index 4efcd46a..e2cfb5d9 100644 --- a/src/shared/unified-search-status-text.ts +++ b/src/shared/unified-search-status-text.ts @@ -3,7 +3,10 @@ import type { UnifiedSearchStatusIncompletePayload, UnifiedSearchStatusResultPayload, } from "./unified-search-response.js"; -import { appendUnifiedSearchHits } from "./unified-search-text.js"; +import { + appendUnifiedSearchHits, + formatProgressTarget, +} from "./unified-search-text.js"; const SEP = " | "; @@ -17,6 +20,17 @@ export function renderUnifiedSearchStatusText(payload: StatusPayload): string { if (!payload.completed && payload.progress) { lines.push(formatProgress(payload.progress)); + if (payload.progress.targets?.length) { + lines.push("progress targets:"); + for (const target of payload.progress.targets) { + lines.push(` - ${formatProgressTarget(target)}`); + } + } + } + + if (!payload.completed && payload.warnings && payload.warnings.length > 0) { + lines.push("warnings:"); + for (const warning of payload.warnings) lines.push(` - ${warning}`); } const result = payload.result; @@ -107,8 +121,10 @@ function formatProgress(progress: { targetsReady: number; targetsTotal: number; elapsedMs: number; + next?: string; }): string { - return `progress: ${progress.status}, ${progress.targetsReady}/${progress.targetsTotal} targets ready, ${progress.elapsedMs}ms elapsed`; + const next = progress.next ? `; next: ${progress.next}` : ""; + return `progress: ${progress.status}, ${progress.targetsReady}/${progress.targetsTotal} targets ready, ${progress.elapsedMs}ms elapsed${next}`; } function quote(value: string): string { diff --git a/src/shared/unified-search-target.ts b/src/shared/unified-search-target.ts index f2294aa1..3e7b8e15 100644 --- a/src/shared/unified-search-target.ts +++ b/src/shared/unified-search-target.ts @@ -1,8 +1,40 @@ import type { CodeNavigationTarget } from "../services/index.js"; -import { parseCodeNavigationTargetSpec } from "./code-navigation-target.js"; +import { toCodeNavigationRegistry } from "./code-navigation.js"; +import { InvalidArgumentError, parsePackageSpec } from "./package-spec.js"; export function parseUnifiedSearchTargetSpec( spec: string, ): CodeNavigationTarget { - return parseCodeNavigationTargetSpec(spec); + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new InvalidArgumentError("Target spec cannot be empty."); + } + + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return parseDiscoveryRepoTarget(trimmed); + } + + const parsed = parsePackageSpec(trimmed); + return { + registry: toCodeNavigationRegistry(parsed.registry), + packageName: parsed.name, + version: parsed.version, + }; +} + +function parseDiscoveryRepoTarget(spec: string): CodeNavigationTarget { + const hashIndex = spec.lastIndexOf("#"); + if (hashIndex === -1) { + return { repoUrl: spec }; + } + + const repoUrl = spec.slice(0, hashIndex); + const gitRef = spec.slice(hashIndex + 1); + if (!repoUrl || !gitRef) { + throw new InvalidArgumentError( + "Repository target must be a full URL with optional #gitRef suffix.", + ); + } + + return { repoUrl, gitRef }; } diff --git a/src/shared/unified-search-text.test.ts b/src/shared/unified-search-text.test.ts index 5de71078..ed89da97 100644 --- a/src/shared/unified-search-text.test.ts +++ b/src/shared/unified-search-text.test.ts @@ -218,6 +218,24 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).toContain("source notes:"); }); + it("does not fabricate HEAD when repo follow-up lacks served gitRef", () => { + const text = renderUnifiedSearchSuccess( + completed([ + codeHit({ + target: "https://github.com/expressjs/express default branch", + locator: { + repoUrl: "https://github.com/expressjs/express", + filePath: "lib/router/index.js", + requestedRef: "main", + }, + }), + ]), + ); + + expect(text).toContain("follow-up unavailable: missing target"); + expect(text).not.toContain("#HEAD"); + }); + it("omits the warnings preamble when no warnings are present", () => { const text = renderUnifiedSearchSuccess(completed([codeHit()])); expect(text).not.toContain("warnings:"); diff --git a/src/shared/unified-search-text.ts b/src/shared/unified-search-text.ts index 207940c1..bebe2efa 100644 --- a/src/shared/unified-search-text.ts +++ b/src/shared/unified-search-text.ts @@ -203,9 +203,51 @@ function buildTrailer(payload: SearchSuccessPayload): string[] { } } + const progress = "progress" in payload ? payload.progress : undefined; + if (progress?.targets?.length) { + lines.push("progress targets:"); + for (const target of progress.targets) { + lines.push(` - ${formatProgressTarget(target)}`); + } + } + return lines; } +export function formatProgressTarget(target: { + requested?: string; + resolvedRequested?: string; + served?: string; + freshness?: string; + indexingRef?: string; + requestedRefKind?: string; +}): string { + const parts: string[] = []; + if (target.requested) parts.push(`requested=${target.requested}`); + if (target.resolvedRequested) parts.push(`fresh=${target.resolvedRequested}`); + if (target.served) parts.push(`served=${target.served}`); + if (target.freshness) + parts.push(`state=${describeFreshness(target.freshness)}`); + if (target.requestedRefKind) parts.push(`intent=${target.requestedRefKind}`); + if (target.indexingRef) parts.push(`indexingRef=${target.indexingRef}`); + return parts.length > 0 ? parts.join(SEP) : "target progress unavailable"; +} + +export function describeFreshness(value: string): string { + switch (value) { + case "PENDING": + case "INDEXING": + return "indexing fresh target"; + case "STALE": + return "served stale evidence"; + case "CURRENT": + case "INDEXED": + return "current"; + default: + return value.toLowerCase(); + } +} + function formatSourceStatus(entry: { source: string; targetLabel: string; diff --git a/src/tools/code-navigation-shared.ts b/src/tools/code-navigation-shared.ts index dfd9abb4..e1f6747e 100644 --- a/src/tools/code-navigation-shared.ts +++ b/src/tools/code-navigation-shared.ts @@ -66,7 +66,7 @@ export const codeTargetSchema = z.union([ .string() .min(1) .describe( - "Compact target string. Package: `npm:react@18.2.0`. Repository: `https://github.com/facebook/react#HEAD` (git ref suffix optional, defaults to HEAD).", + "Compact target string. Package: `npm:react@18.2.0`. Repository: `https://github.com/facebook/react#HEAD` (git ref suffix required for exact code navigation).", ), ]); @@ -134,10 +134,9 @@ export function resolveCodeTarget( ); } - return { - repoUrl: target.repo_url, - gitRef: target.git_ref ?? "HEAD", - }; + return invalidTargetResult( + "Incomplete repository target: git_ref is required for exact code navigation.", + ); } return { diff --git a/src/tools/list-files.test.ts b/src/tools/list-files.test.ts index 76b1a522..e1105c36 100644 --- a/src/tools/list-files.test.ts +++ b/src/tools/list-files.test.ts @@ -285,6 +285,18 @@ describe("createListFilesTool — validation errors", () => { const payload = parseText(result) as { code: string }; expect(payload.code).toBe("INVALID_ARGUMENT"); }); + + it("returns INVALID_ARGUMENT for repo targets without git refs", async () => { + const tool = createListFilesTool(createMockCodeNavigationService()); + const result = await tool.handler( + { target: "https://github.com/expressjs/express" }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("#gitRef"); + }); }); describe("createListFilesTool — service errors", () => { diff --git a/src/tools/list-package-docs.test.ts b/src/tools/list-package-docs.test.ts index 84e6b33e..0b369c06 100644 --- a/src/tools/list-package-docs.test.ts +++ b/src/tools/list-package-docs.test.ts @@ -75,6 +75,43 @@ describe("createListPackageDocsTool", () => { expect(() => JSON.parse(text)).toThrow(); }); + it("uses served gitRef for repo-backed docs text follow-ups", async () => { + const tool = createListPackageDocsTool( + createMockPackageIntelligenceService({ + listPackageDocs: mock(() => + Promise.resolve({ + registry: "npm", + packageName: "ms", + version: "2.1.3", + pages: [ + { + id: "github:vercel/ms@sha/readme.md", + title: "readme.md", + sourceKind: "REPOSITORY", + sourceUrl: "https://github.com/vercel/ms/blob/sha/readme.md", + repoUrl: "https://github.com/vercel/ms", + gitRef: "served-sha", + requestedRef: "main", + filePath: "readme.md", + }, + ], + pageInfo: { hasNextPage: false }, + } satisfies PackageDocsList), + ), + }), + ); + + const result = await tool.handler( + { registry: "npm", package_name: "ms" }, + {}, + ); + const text = result.content[0]?.text ?? ""; + expect(text).toContain( + 'code_read target="https://github.com/vercel/ms#served-sha"', + ); + expect(text).not.toContain("#main"); + }); + it("omits nullish lastUpdatedAt values from the lean envelope", async () => { const tool = createListPackageDocsTool( createMockPackageIntelligenceService({ diff --git a/src/tools/search-status.test.ts b/src/tools/search-status.test.ts index ef8cae47..fe94a2f9 100644 --- a/src/tools/search-status.test.ts +++ b/src/tools/search-status.test.ts @@ -105,6 +105,34 @@ describe("searchStatusTool", () => { expect(payload.progress.status).toBe("TIMEOUT"); }); + it("surfaces progress freshness warnings", async () => { + const tool = createSearchStatusTool( + createMockCodeNavigationService({ + searchStatus: mock(() => + Promise.resolve( + createIncompleteOutcome("INDEXING", "ref-stale", { + targets: [ + { + requested: "npm:express latest", + resolvedRequested: "npm:express@5.2.1", + served: "npm:express@5.1.0", + freshness: "STALE", + }, + ], + }), + ), + ), + }), + ); + + const result = await tool.handler({ search_ref: "ref-stale" }, {}); + const text = result.content[0]?.text ?? ""; + expect(text).toContain("warnings:"); + expect(text).toContain( + "requested npm:express latest; served stale npm:express@5.1.0 while npm:express@5.2.1 indexes.", + ); + }); + it("defaults to compact text output", async () => { const tool = createSearchStatusTool( createMockCodeNavigationService({ diff --git a/src/tools/search.test.ts b/src/tools/search.test.ts index ea62cbb2..b2d21712 100644 --- a/src/tools/search.test.ts +++ b/src/tools/search.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, mock } from "bun:test"; -import type { UnifiedSearchOutcome } from "../services/index.js"; +import type { + UnifiedSearchOutcome, + UnifiedSearchParams, +} from "../services/index.js"; import { createMockCodeNavigationService, defaultUnifiedSearchOutcome, @@ -26,7 +29,9 @@ describe("searchTool", () => { }); it("passes compiled request through to code navigation service", async () => { - const search = mock(() => Promise.resolve(defaultUnifiedSearchOutcome)); + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); const tool = createSearchTool(createMockCodeNavigationService({ search })); await tool.handler( @@ -51,7 +56,9 @@ describe("searchTool", () => { }); it("accepts compact package string targets", async () => { - const search = mock(() => Promise.resolve(defaultUnifiedSearchOutcome)); + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); const tool = createSearchTool(createMockCodeNavigationService({ search })); await tool.handler( @@ -76,7 +83,9 @@ describe("searchTool", () => { }); it("accepts compact repo string targets inside targets arrays", async () => { - const search = mock(() => Promise.resolve(defaultUnifiedSearchOutcome)); + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); const tool = createSearchTool(createMockCodeNavigationService({ search })); await tool.handler( @@ -110,8 +119,10 @@ describe("searchTool", () => { ); }); - it("defaults repo targets to HEAD when git_ref is omitted", async () => { - const search = mock(() => Promise.resolve(defaultUnifiedSearchOutcome)); + it("preserves omitted repo refs for backend default-branch discovery", async () => { + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); const tool = createSearchTool(createMockCodeNavigationService({ search })); await tool.handler( @@ -122,16 +133,54 @@ describe("searchTool", () => { {}, ); - expect(search).toHaveBeenCalledWith( - expect.objectContaining({ + const call = search.mock.calls[0]?.[0]; + expect(call?.targets[0]).toEqual({ + repoUrl: "https://github.com/expressjs/express", + }); + }); + + it("preserves omitted repo refs in compact target strings", async () => { + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); + const tool = createSearchTool(createMockCodeNavigationService({ search })); + + await tool.handler( + { + query: "router middleware", + target: "https://github.com/expressjs/express", + }, + {}, + ); + + const call = search.mock.calls[0]?.[0]; + expect(call?.targets[0]).toEqual({ + repoUrl: "https://github.com/expressjs/express", + }); + }); + + it("accepts mixed package and repo targets", async () => { + const search = mock((_: UnifiedSearchParams) => + Promise.resolve(defaultUnifiedSearchOutcome), + ); + const tool = createSearchTool(createMockCodeNavigationService({ search })); + + await tool.handler( + { + query: "router middleware", targets: [ - expect.objectContaining({ - repoUrl: "https://github.com/expressjs/express", - gitRef: "HEAD", - }), + "npm:express@5.1.0", + { repo_url: "https://github.com/expressjs/express" }, ], - }), + }, + {}, ); + + const call = search.mock.calls[0]?.[0]; + expect(call?.targets).toEqual([ + { registry: "NPM", packageName: "express", version: "5.1.0" }, + { repoUrl: "https://github.com/expressjs/express" }, + ]); }); it("preserves locator fields on repository_doc hits so agents can call read_package_doc or read_file", async () => { @@ -187,7 +236,7 @@ describe("searchTool", () => { gitRef: "abc123", filePath: "README.md", }); - expect(payload.results[0]).not.toHaveProperty("followUp"); + expect(payload.results[0].followUp).toContain("docs_read page_id="); expect(payload.results[0]).not.toHaveProperty("alternateFollowUps"); }); diff --git a/src/tools/search.ts b/src/tools/search.ts index fa4aef2b..66197719 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -1,5 +1,10 @@ import { z } from "zod"; -import type { CodeNavigationService } from "../services/index.js"; +import type { + CodeNavigationService, + CodeNavigationTarget, +} from "../services/index.js"; +import { toCodeNavigationRegistry } from "../shared/code-navigation.js"; +import { mapCodeNavigationError } from "../shared/code-navigation-error-map.js"; import { buildUnifiedSearchErrorPayload, buildUnifiedSearchParams, @@ -10,16 +15,21 @@ import { toSymbolCategory, toSymbolKind, } from "../shared/index.js"; +import { parseUnifiedSearchTargetSpec } from "../shared/unified-search-target.js"; import { type CodeTargetArg, - codeTargetSchema, - resolveCodeTarget, + structuredCodeTargetSchema, } from "./code-navigation-shared.js"; -import { errorResult, type ToolDefinition, textResult } from "./types.js"; +import { + errorResult, + type ToolDefinition, + type ToolResult, + textResult, +} from "./types.js"; -type ResolvedCodeTarget = Exclude< - ReturnType, - { content: unknown } +type ResolvedSearchTarget = Exclude< + ReturnType, + ToolResult >; export interface SearchArgs { @@ -77,6 +87,16 @@ export interface SearchArgs { format?: "json" | "text" | "text-v1"; } +const searchTargetSchema = z.union([ + structuredCodeTargetSchema, + z + .string() + .min(1) + .describe( + "Compact discovery target string. Package: `npm:react@18.2.0`. Repository: `https://github.com/facebook/react` for backend default branch or `https://github.com/facebook/react#HEAD` for an explicit ref.", + ), +]); + const schema = { query: z .string() @@ -84,8 +104,8 @@ const schema = { .describe( "Discovery query string. Supports implicit AND, uppercase OR, parentheses, unary -, quoted phrases, semantic qualifiers (kind:, category:, path:, lang:, name:, intent:), and routing qualifiers (registry:, package:, version:, repo:). Parsed once and compiled per source; it is not forwarded as a raw backend query.", ), - target: codeTargetSchema.optional(), - targets: z.array(codeTargetSchema).max(20).optional(), + target: searchTargetSchema.optional(), + targets: z.array(searchTargetSchema).max(20).optional(), sources: z .array(z.enum(["docs", "code", "symbol"])) .optional() @@ -187,13 +207,13 @@ export function createSearchTool( handler: async (args) => { try { const resolvedTarget = args.target - ? resolveCodeTarget(args.target) + ? resolveSearchTarget(args.target) : undefined; if (resolvedTarget && "content" in resolvedTarget) return resolvedTarget; const resolvedTargets = args.targets?.map((entry) => - resolveCodeTarget(entry), + resolveSearchTarget(entry), ); const resolvedTargetsError = resolvedTargets?.find( (entry) => "content" in entry, @@ -207,7 +227,7 @@ export function createSearchTool( resolvedTarget && !("content" in resolvedTarget) ? resolvedTarget : undefined, - targets: resolvedTargets?.filter(isResolvedCodeTarget), + targets: resolvedTargets?.filter(isResolvedSearchTarget), query: args.query, sources: args.sources?.map( (entry) => entry.toUpperCase() as "DOCS" | "CODE" | "SYMBOL", @@ -247,12 +267,73 @@ export function createSearchTool( }; } -function isResolvedCodeTarget( - target: ReturnType, -): target is ResolvedCodeTarget { +function isResolvedSearchTarget( + target: ReturnType, +): target is ResolvedSearchTarget { return !("content" in target); } +function resolveSearchTarget( + target: CodeTargetArg, +): CodeNavigationTarget | ToolResult { + if (typeof target === "string") { + try { + return parseUnifiedSearchTargetSpec(target); + } catch (error) { + const mapped = mapCodeNavigationError(error); + return errorResult( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + } + } + + const hasPackageTarget = Boolean(target.registry || target.package_name); + const hasRepoTarget = Boolean(target.repo_url || target.git_ref); + if (hasPackageTarget && hasRepoTarget) { + return invalidSearchTargetResult( + "Invalid target: provide either registry + package_name or repo_url + git_ref, not both.", + ); + } + if (!hasPackageTarget && !hasRepoTarget) { + return invalidSearchTargetResult( + "Missing target: provide registry + package_name or repo_url.", + ); + } + if (hasPackageTarget) { + if (!target.registry || !target.package_name) { + return invalidSearchTargetResult( + "Incomplete package target: both registry and package_name are required.", + ); + } + return { + registry: toCodeNavigationRegistry(target.registry), + packageName: target.package_name, + version: target.version, + }; + } + if (!target.repo_url) { + return invalidSearchTargetResult( + "Incomplete repository target: repo_url is required.", + ); + } + return { repoUrl: target.repo_url, gitRef: target.git_ref }; +} + +function invalidSearchTargetResult(message: string): ToolResult { + return errorResult( + JSON.stringify({ + error: message, + code: "INVALID_ARGUMENT", + retryable: false, + }), + ); +} + /** * Default response format is text-v1 — agents consume the MCP surface * and benefit from the compact form. Programmatic / parity callers From 0326a7291b1daa42ce0f325da1d786608b0bfe43 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 5 May 2026 11:22:05 +0300 Subject: [PATCH 2/3] test: suppress schema mismatch debug logs Keeps debug-path coverage while preventing expected diagnostic JSON from polluting normal test output. --- src/services/code-navigation-service.test.ts | 4 ++++ src/services/package-intelligence-service.test.ts | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/services/code-navigation-service.test.ts b/src/services/code-navigation-service.test.ts index 97279b97..d042dcb8 100644 --- a/src/services/code-navigation-service.test.ts +++ b/src/services/code-navigation-service.test.ts @@ -803,6 +803,9 @@ describe("CodeNavigationServiceImpl", () => { it("exposes GraphQL schema mismatch details when code-nav-wire debug is enabled", async () => { process.env.GITHITS_DEBUG = "code-nav-wire"; + const stderrSpy = spyOn(process.stderr, "write").mockImplementation( + () => true as never, + ); mockFetch(() => Promise.resolve( new Response( @@ -832,6 +835,7 @@ describe("CodeNavigationServiceImpl", () => { name: "CodeNavigationBackendError", message: 'Cannot query field "search" on type "Query".', }); + stderrSpy.mockRestore(); }); it("honors explicit backend CLIENT_UPDATE_REQUIRED errors", async () => { diff --git a/src/services/package-intelligence-service.test.ts b/src/services/package-intelligence-service.test.ts index 9150890f..fbaa8397 100644 --- a/src/services/package-intelligence-service.test.ts +++ b/src/services/package-intelligence-service.test.ts @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; import { AuthenticationError } from "./githits-service.js"; import { MalformedPackageIntelligenceResponseError, @@ -457,6 +465,9 @@ describe("PackageIntelligenceServiceImpl", () => { it("exposes GraphQL schema mismatch details when pkg-graphql debug is enabled", async () => { process.env.GITHITS_DEBUG = "pkg-graphql"; + const stderrSpy = spyOn(process.stderr, "write").mockImplementation( + () => true as never, + ); const fetchFn = mock(() => Promise.resolve( jsonResponse({ @@ -481,6 +492,7 @@ describe("PackageIntelligenceServiceImpl", () => { name: "PackageIntelligenceBackendError", message: 'Cannot query field "packageSummary" on type "Query".', }); + stderrSpy.mockRestore(); }); it("honors explicit backend CLIENT_UPDATE_REQUIRED errors", async () => { From d5d7217e02a22b894fe7a28069ea20c0ffbbc28c Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 5 May 2026 14:07:01 +0300 Subject: [PATCH 3/3] fix: compact repo doc search output Removes repeated page IDs, source URLs, and exact commit refs from default terminal repo-doc search results while preserving JSON fidelity. --- src/commands/search.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/commands/search.ts b/src/commands/search.ts index 58fa39c0..006224af 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -813,7 +813,9 @@ function formatUnifiedSearchMetadata( const lines: string[] = []; if (entry.locator.pageId) { - lines.push(` ${dim("pageId:", useColors)} ${entry.locator.pageId}`); + if (entry.type === "documentation_page") { + lines.push(` ${dim("pageId:", useColors)} ${entry.locator.pageId}`); + } } const sourceBadge = @@ -822,18 +824,11 @@ function formatUnifiedSearchMetadata( : entry.locator.sourceKind?.toLowerCase() === "crawled" ? "[crawled]" : undefined; - if (entry.locator.sourceUrl) { + if (entry.locator.sourceUrl && entry.type === "documentation_page") { lines.push( ` ${dim("source:", useColors)} ${sourceBadge ? `${sourceBadge} ` : ""}${entry.locator.sourceUrl}`, ); } - if (entry.type === "repository_doc" && entry.locator.filePath) { - const ref = entry.locator.gitRef; - lines.push( - ` ${dim("file:", useColors)} ${entry.locator.filePath}${ref ? ` @ ${ref}` : ""}`, - ); - } - return lines; }