diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 7a6b30ca..b9ebc586 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -68,7 +68,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit **Sources and filters.** `--source docs|code|symbol` is repeatable; omitting it delegates source selection to backend AUTO. Use `--source symbol` when you want symbol-shaped search results without dropping down to the older `code search` surface. `--category` is the broad filter (`callable`, `type`, `module`, `data`, `documentation`); `--kind` is the precise taxonomy. `--path-prefix`, `--intent`, and `--public` narrow the result set further. `--name` and `--lang` compile into query qualifiers instead of becoming separate backend fields. -**Production intent by default.** When `--intent` is omitted, unified search defaults to `production` intent for AUTO, code, and symbol searches. This removes test / benchmark / example noise where the backend supports the filter. Explicit docs-only searches do not get a file-intent default because docs do not support that filter. Some sources can still ignore `fileIntent`; when they do, the JSON `sourceStatus` block and terminal notes report that explicitly. +**Intent filter.** When `--intent` is omitted, unified search sends no file-intent filter. Pass `--intent production` or another specific intent only when you want to narrow the result set. Some sources can still ignore `fileIntent`; when they do, the JSON `sourceStatus` block and terminal notes report that explicitly. **Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. If indexing does not complete within the wait window, the default behavior returns a `searchRef` and progress summary instead of partial hits. With `--allow-partial`, available hits are included while remaining sources continue indexing. `--wait` is in seconds (0-60, default 20). @@ -126,13 +126,13 @@ Finds functions, classes, modules, and doc sections inside an indexed dependency **Filtering by symbol shape.** Prefer `--category` for broad filtering (`callable`, `type`, `module`, `data`, `documentation`) — it works across the full 27-value kind taxonomy without enumerating individual kinds. Reach for `--kind` when you want a specific construct, e.g. `--kind trait` (Rust) or `--kind namespace` (C#/C++/PHP). -**Defaults.** `--intent production` filters to production source by default so top results are not dominated by tests, benchmarks, or examples. Use `--intent all` to include every file intent. `--wait` defaults to 20 seconds (above the p50 indexing time of ~11 s); first-time queries against an unindexed package may need `--wait 60` (the backend ceiling) to block until indexing completes. On an INDEXING error, the response message points out the retry options. +**Defaults.** No file-intent filter is sent by default. Pass `--intent production` or another specific intent to narrow results; `--intent all` remains accepted as an explicit no-filter alias. `--wait` defaults to 20 seconds (above the p50 indexing time of ~11 s); first-time queries against an unindexed package may need `--wait 60` (the backend ceiling) to block until indexing completes. On an INDEXING error, the response message points out the retry options. **Output.** Default terminal output leads each entry with `path:startLine-endLine [kind]`, followed by the symbol name and a 3-line dedented snippet. `--json` emits the shared success/error envelope also produced by the MCP `search_symbols` tool — see [`mcp-cli-parity.md`](./mcp-cli-parity.md) for the wire contract. The command is registered as `code search` with `code search-symbols` as a Commander alias. **Capability gate.** The `code` group is registered only when the startup token explicitly carries `code_navigation`, or when `GITHITS_CODE_NAVIGATION=1` is set for local override. -**Troubleshooting.** Set `GITHITS_DEBUG=code-nav` to emit single-line JSON diagnostics to stderr on error paths. Include the output when filing an issue. Debug payloads never contain query text, tokens, or response bodies. +**Troubleshooting.** Set `GITHITS_DEBUG=code-nav` to emit single-line JSON diagnostics to stderr without query text, tokens, or response bodies. When you need the exact code-navigation wire payload, `GITHITS_DEBUG=code-nav-wire` logs the GraphQL document plus serialized variables, which includes query text and resolved target values; it is explicit-only and is not enabled by `GITHITS_DEBUG=*`. ### `githits pkg info` @@ -155,7 +155,7 @@ Shows a concise overview for a single package: latest version, license, descript **Capability gate.** Same as `code`. -**Troubleshooting.** `GITHITS_DEBUG=pkg-intel` emits PII-safe classified-error diagnostics (area, event, code, error class, detail keys). Use `GITHITS_DEBUG=*` to enable all package/source diagnostics. +**Troubleshooting.** `GITHITS_DEBUG=pkg-intel` emits PII-safe classified-error diagnostics (area, event, code, error class, detail keys). Use `GITHITS_DEBUG=*` to enable all non-sensitive package/source diagnostics. ### `githits pkg vulns` diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 8b00f724..471ed39f 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -52,9 +52,9 @@ the test suite anchors the doc. `src/shared/code-navigation-defaults.ts`. They never diverge silently. - Cross-tool defaults (e.g. `DEFAULT_WAIT_TIMEOUT_MS`) live without a - prefix. Tool-specific defaults carry a tool-name prefix - (`SEARCH_SYMBOLS_DEFAULT_FILE_INTENT`) so tool #2 declaring its own - defaults in the same file does not cause naming churn. + prefix. Tool-local sentinels that affect request shaping (for example + `FILE_INTENT_ALL`) also live there so both surfaces translate them the + same way. - When a surface fills in a default for the caller, that default value is applied at the shared request builder (`buildSearchSymbolsParams`) — not at the surface — so both @@ -86,7 +86,7 @@ the test suite anchors the doc. a string array naming the fields whose values the client filled in. Empty array when every field was caller-set. - `fileIntent` is echoed as a lowercase enum value, or the literal - `"all"` when the caller chose the all-intents sentinel. + `"all"` when no file-intent filter was applied. - `returnedCount` is an explicit echo of `results.length`. - `totalMatches` is the service-provided total (equal to `returnedCount` today). @@ -160,7 +160,7 @@ When a new tool lands with both MCP and CLI surfaces: | File | Role | |---|---| -| `src/shared/code-navigation-defaults.ts` | Canonical defaults and sentinels. | +| `src/shared/code-navigation-defaults.ts` | Canonical cross-surface defaults and sentinels. | | `src/shared/code-navigation-error-map.ts` | `mapCodeNavigationError` classifier and `MappedError` union. | | `src/shared/pkgseer-graphql.ts` | Low-level authenticated package/source POST helper shared by the service clients. | | `src/shared/pkgseer-registry.ts` | Registry taxonomy (`PkgseerRegistry` union + lowercase↔uppercase converters). | diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 93cd1f9a..0d18bf7c 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -22,7 +22,7 @@ Both expose the same tools with identical names, parameters, and descriptions. T | `get_example` | `query`, `language`, `license_mode?` | Search for canonical code examples. Returns markdown-formatted results. | | `search_language` | `query` | Find supported programming language names before searching. | | `feedback` | `solution_id`, `accepted`, `feedback_text?` | Submit feedback on a search result to improve quality. | -| `search` | `query`, `target?`, `targets?`, `sources?`, `category?`, `kind?`, `path_prefix?`, `file_intent?`, `public_only?`, `name?`, `language?`, `allow_partial_results?`, `limit?`, `offset?`, `wait_timeout_ms?` | Unified indexed dependency/repository discovery search across code, docs, and symbols. Defaults `file_intent` to production for AUTO/code/symbol searches when omitted; explicit docs-only searches do not get a file-intent default. Prefer `sources:["symbol"]` for symbol-shaped unified search. Complete-by-default; set `allow_partial_results: true` to receive available partial hits while indexing continues. | +| `search` | `query`, `target?`, `targets?`, `sources?`, `category?`, `kind?`, `path_prefix?`, `file_intent?`, `public_only?`, `name?`, `language?`, `allow_partial_results?`, `limit?`, `offset?`, `wait_timeout_ms?` | Unified indexed dependency/repository discovery search across code, docs, and symbols. Omit `file_intent` to search across all intents; set it only when you want to narrow results, and note that some sources may ignore it and report that in `sourceStatus`. Prefer `sources:["symbol"]` for symbol-shaped unified search. Complete-by-default; set `allow_partial_results: true` to receive available partial hits while indexing continues. | | `search_status` | `search_ref` | Check progress, fetch partial hits when the original request used `allow_partial_results: true`, or fetch final results for a prior unified search. | | `package_summary` | `registry`, `package_name` | Package overview: latest version, license, description, repository, downloads, GitHub metadata, install command, and known vulnerabilities. Always returns the latest published version. | | `package_vulnerabilities` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Count summary, per-advisory OSV ID + severity + affected/fix ranges, and upgrade paths. Malware is surfaced in a disjoint bucket. | diff --git a/src/commands/code/search-symbols.test.ts b/src/commands/code/search-symbols.test.ts index de35ed2c..c334c543 100644 --- a/src/commands/code/search-symbols.test.ts +++ b/src/commands/code/search-symbols.test.ts @@ -63,8 +63,8 @@ describe("searchSymbolsAction", () => { packageName: "express", version: undefined, }); - expect(payload.query.fileIntent).toBe("production"); - expect(payload.query.defaulted).toContain("fileIntent"); + expect(payload.query.fileIntent).toBe("all"); + expect(payload.query.defaulted).not.toContain("fileIntent"); expect(payload.query.defaulted).toContain("waitTimeoutMs"); expect(payload._warning).toBeUndefined(); consoleSpy.mockRestore(); @@ -189,7 +189,7 @@ describe("searchSymbolsAction", () => { exitSpy.mockRestore(); }); - it("applies --intent production by default and --intent all to opt out", async () => { + it("sends no file-intent filter by default and preserves explicit intent choices", async () => { const logSpy = spyOn(console, "log").mockImplementation(() => {}); const searchSymbols = mock< ( @@ -203,7 +203,7 @@ describe("searchSymbolsAction", () => { }); await searchSymbolsAction("npm:express", "middleware", {}, deps); - expect(searchSymbols.mock.calls[0]?.[0]?.fileIntent).toBe("PRODUCTION"); + expect(searchSymbols.mock.calls[0]?.[0]?.fileIntent).toBeUndefined(); searchSymbols.mockClear(); await searchSymbolsAction( @@ -395,13 +395,13 @@ describe("searchSymbolsAction", () => { consoleSpy.mockRestore(); }); - it("adapts zero-result suggestions to the filters the caller actually used", async () => { + it("suggests --intent all only when the caller explicitly narrowed file intent", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); await searchSymbolsAction( "npm:express", "Router", - { file: "nonexistent/", kind: "function" }, + { file: "nonexistent/", kind: "function", intent: "test" }, createDeps({ codeNavigationService: createMockCodeNavigationService({ searchSymbols: mock(() => @@ -450,6 +450,33 @@ describe("searchSymbolsAction", () => { consoleSpy.mockRestore(); }); + it("omits `try --intent all` from the zero-result suggestion when no intent filter was sent", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchSymbolsAction( + "npm:express", + "Router", + {}, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + searchSymbols: mock(() => + Promise.resolve({ + results: [], + totalMatches: 0, + hasMore: false, + version: "v5.2.1", + }), + ), + }), + }), + ); + + const output = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain("try broader keywords"); + expect(output).not.toContain("try --intent all"); + consoleSpy.mockRestore(); + }); + it("accepts --category and passes through to the service and echo", async () => { const searchSymbols = mock< ( diff --git a/src/commands/code/search-symbols.ts b/src/commands/code/search-symbols.ts index cb527d6e..e65128e7 100644 --- a/src/commands/code/search-symbols.ts +++ b/src/commands/code/search-symbols.ts @@ -125,8 +125,9 @@ Filter by --category (broad: callable, type, module, data, documentation) or --kind (precise: function, method, class, trait, …). Prefer --category for most use cases; reach for --kind when you need a specific construct. -Default file intent is production source. Pass --intent all to include tests, -examples, benchmarks, generated files, and other non-production code. +By default no file-intent filter is sent. Pass --intent production (or another +specific intent) to narrow results; --intent all remains accepted as an explicit +no-filter alias. Examples: githits code search npm:express middleware @@ -172,7 +173,7 @@ export function registerCodeSearchSymbolsCommand(program: Command) { .option("--file ", "Filter to files matching path prefix") .option( "--intent ", - "File intent filter (production, test, benchmark, example, generated, fixture, build, vendor, all). Default: production.", + "File intent filter (production, test, benchmark, example, generated, fixture, build, vendor, all). Omit for no filter; `all` is an explicit alias.", ) .option( "--wait ", diff --git a/src/commands/mcp-instructions.ts b/src/commands/mcp-instructions.ts index a0ec72bb..0395da49 100644 --- a/src/commands/mcp-instructions.ts +++ b/src/commands/mcp-instructions.ts @@ -36,7 +36,7 @@ const PACKAGE_CHANGELOG_BULLET = "- `package_changelog` — release notes for a package or GitHub repo, newest-first. Default latest mode returns the 10 most recent entries with full markdown bodies; `from_version` switches to range mode between two versions. Addressable via `registry` + `package_name` or `repo_url`. Set `include_bodies: false` for a version / date / URL timeline when bodies aren't needed."; const SEARCH_BULLET = - "- `search` — unified search across indexed dependency code, docs, and explicit symbols. Structured fields are the primary UX; omit `sources` for AUTO. Production file intent is applied by default where supported, but some sources may ignore that filter and report it in `sourceStatus`. Returns only trustworthy complete results by default; opt into partial hits with `allow_partial_results: true`. If indexing is still in progress, the response carries a `searchRef`."; + "- `search` — unified search across indexed dependency code, docs, and explicit symbols. Structured fields are the primary UX; omit `sources` for AUTO. Omit `file_intent` to search across all intents, or set it to narrow results; some sources may ignore that filter and report it in `sourceStatus`. Returns only trustworthy complete results by default; opt into partial hits with `allow_partial_results: true`. If indexing is still in progress, the response carries a `searchRef`."; const SEARCH_STATUS_BULLET = "- `search_status` — follow up a prior unified search by `searchRef`. Use it after `search` returns incomplete state to check progress, fetch partial hits when the original request used `allow_partial_results: true`, or fetch final results."; diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 04dff76c..6ca3132e 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -108,6 +108,42 @@ describe("searchAction", () => { consoleSpy.mockRestore(); }); + it("does not send a file-intent filter unless the caller explicitly set one", async () => { + const search = mock< + ( + params: import("../services/code-navigation-service.js").UnifiedSearchParams, + ) => Promise< + import("../services/code-navigation-service.js").UnifiedSearchOutcome + > + >(() => Promise.resolve(defaultUnifiedSearchOutcome)); + const deps = createDeps({ + codeNavigationService: createMockCodeNavigationService({ search }), + }); + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await searchAction( + "router middleware", + { + in: ["npm:express"], + }, + deps, + ); + expect(search.mock.calls[0]?.[0]?.filters?.fileIntent).toBeUndefined(); + + search.mockClear(); + await searchAction( + "router middleware", + { + in: ["npm:express"], + intent: "test", + }, + deps, + ); + expect(search.mock.calls[0]?.[0]?.filters?.fileIntent).toBe("TEST"); + + consoleSpy.mockRestore(); + }); + it("outputs JSON when --json flag provided", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); @@ -224,49 +260,6 @@ describe("searchAction", () => { consoleSpy.mockRestore(); }); - it("suppresses ignored-filter notes for auto-defaulted filters", async () => { - const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - - if (defaultUnifiedSearchOutcome.state !== "completed") { - throw new Error("expected completed outcome fixture"); - } - const completedOutcome = defaultUnifiedSearchOutcome; - const outcomeWithIgnoredFilters: UnifiedSearchOutcome = { - ...completedOutcome, - result: { - ...completedOutcome.result, - sourceStatus: [ - { - source: "DOCS", - targetLabel: "npm:express@4.18.2", - indexingStatus: "INDEXED", - resultCount: 1, - ignoredFilters: ["fileIntent"], - incompatibleFilters: [], - appliedFilters: [], - appliedQueryFeatures: [], - ignoredQueryFeatures: [], - incompatibleQueryFeatures: [], - }, - ], - }, - }; - - await searchAction( - "router middleware", - { in: ["npm:express"] }, - createDeps({ - codeNavigationService: createMockCodeNavigationService({ - search: mock(() => Promise.resolve(outcomeWithIgnoredFilters)), - }), - }), - ); - - const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).not.toContain("ignored filters: fileIntent"); - consoleSpy.mockRestore(); - }); - it("renders ignored and incompatible query-feature notes", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); diff --git a/src/commands/search.ts b/src/commands/search.ts index 7190067d..2a856aa7 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -154,8 +154,8 @@ const SEARCH_DESCRIPTION = `Search code, docs, and symbols across indexed depend Use repeatable --in targets in package form (npm:express[@version]) or repo form (https://github.com/org/repo[#ref]). Structured flags are AND-combined with -the discovery query. Unified search defaults to production file intent where the -backend supports that filter. Results are complete-by-default: if indexing is +the discovery query. Omit --intent to search across all file intents; pass it +only when you want to narrow results. Results are complete-by-default: if indexing is still in progress, search returns a searchRef instead of partial hits unless --allow-partial is passed. @@ -230,7 +230,7 @@ export function registerSearchCommand(program: Command) { .addOption( new Option( "--intent ", - "File intent filter (default: production for AUTO/code/symbol, omitted for docs-only)", + "File intent filter (omit to search across all intents)", ).choices([ "production", "test", diff --git a/src/services/code-navigation-service.test.ts b/src/services/code-navigation-service.test.ts index ca9faf5a..bd38b9f1 100644 --- a/src/services/code-navigation-service.test.ts +++ b/src/services/code-navigation-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 { CodeNavigationBackendError, CodeNavigationFileNotFoundError, @@ -23,6 +31,7 @@ function mockFetch(impl: () => Promise) { describe("CodeNavigationServiceImpl", () => { const BASE_URL = "https://nav.example.com"; let originalFetch: typeof globalThis.fetch; + const originalDebug = process.env.GITHITS_DEBUG; beforeEach(() => { originalFetch = globalThis.fetch; @@ -30,6 +39,8 @@ describe("CodeNavigationServiceImpl", () => { afterEach(() => { globalThis.fetch = originalFetch; + if (originalDebug === undefined) delete process.env.GITHITS_DEBUG; + else process.env.GITHITS_DEBUG = originalDebug; }); it("sends GraphQL request and normalizes search results", async () => { @@ -92,6 +103,112 @@ describe("CodeNavigationServiceImpl", () => { expect(body.variables.query).toBe("middleware"); }); + it("emits safe debug logging for searchSymbols request shape without query text", async () => { + process.env.GITHITS_DEBUG = "code-nav"; + const stderrSpy = spyOn(process.stderr, "write").mockImplementation( + () => true as never, + ); + const fn = mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + searchSymbols: { + results: [], + totalMatches: 0, + hasMore: false, + indexedVersion: "4.18.0", + diagnostics: { hint: null }, + warning: null, + codeIndexState: "CURRENT", + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express", version: "4.18.0" }, + query: "middleware secret text", + waitTimeoutMs: 5000, + }); + + const call = stderrSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(call.trimEnd()); + expect(parsed.area).toBe("code-nav"); + expect(parsed.event).toBe("request"); + expect(parsed.operation).toBe("searchSymbols"); + expect(parsed.fileIntent).toBe("omitted"); + expect(parsed.queryMode).toBe("query"); + expect(parsed.presentVariableKeys).not.toContain("fileIntent"); + expect(call).not.toContain("middleware secret text"); + expect(fn).toHaveBeenCalledTimes(1); + stderrSpy.mockRestore(); + }); + + it("emits exact GraphQL and serialized variables for searchSymbols under code-nav-wire", async () => { + process.env.GITHITS_DEBUG = "code-nav-wire"; + const stderrSpy = spyOn(process.stderr, "write").mockImplementation( + () => true as never, + ); + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + searchSymbols: { + results: [], + totalMatches: 0, + hasMore: false, + indexedVersion: "4.18.0", + diagnostics: { hint: null }, + warning: null, + codeIndexState: "CURRENT", + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + await service.searchSymbols({ + target: { registry: "NPM", packageName: "express", version: "4.18.0" }, + query: "middleware secret text", + waitTimeoutMs: 5000, + }); + + const call = stderrSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(call.trimEnd()); + expect(parsed.area).toBe("code-nav-wire"); + expect(parsed.event).toBe("wire-request"); + expect(parsed.operation).toBe("searchSymbols"); + expect(parsed.graphqlQuery).toContain("query SearchSymbols("); + expect(parsed.graphqlQuery).toContain("fileIntent: $fileIntent"); + expect(parsed.variables).toEqual({ + registry: "NPM", + packageName: "express", + query: "middleware secret text", + version: "4.18.0", + waitTimeoutMs: 5000, + }); + stderrSpy.mockRestore(); + }); + it("retries once on 401 with refreshed token", async () => { const responses = [ Promise.resolve(new Response("", { status: 401 })), @@ -1168,6 +1285,132 @@ describe("CodeNavigationServiceImpl", () => { expect(body.variables.allowPartialResults).toBe(true); }); + it("emits safe debug logging for unified search request shape without query text", async () => { + process.env.GITHITS_DEBUG = "code-nav"; + const stderrSpy = spyOn(process.stderr, "write").mockImplementation( + () => true as never, + ); + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + search: { + completed: true, + searchRef: "search-ref-123", + result: { + query: "router middleware", + queryWarnings: [], + sources: ["CODE"], + 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: [{ registry: "NPM", packageName: "express" }], + query: "router middleware secret text", + allowPartialResults: false, + waitTimeoutMs: 20_000, + }); + + const call = stderrSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(call.trimEnd()); + expect(parsed.area).toBe("code-nav"); + expect(parsed.event).toBe("request"); + expect(parsed.operation).toBe("search"); + expect(parsed.fileIntent).toBe("omitted"); + expect(parsed.hasFilters).toBe(false); + expect(parsed.presentVariableKeys).not.toContain("filters"); + expect(call).not.toContain("router middleware secret text"); + stderrSpy.mockRestore(); + }); + + it("emits exact GraphQL and serialized variables for unified search under code-nav-wire", async () => { + process.env.GITHITS_DEBUG = "code-nav-wire"; + const stderrSpy = spyOn(process.stderr, "write").mockImplementation( + () => true as never, + ); + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + search: { + completed: true, + searchRef: "search-ref-123", + result: { + query: "router middleware", + queryWarnings: [], + sources: ["CODE"], + 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: [{ registry: "NPM", packageName: "express" }], + query: "router middleware secret text", + allowPartialResults: false, + waitTimeoutMs: 20_000, + }); + + const call = stderrSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(call.trimEnd()); + expect(parsed.area).toBe("code-nav-wire"); + expect(parsed.event).toBe("wire-request"); + expect(parsed.operation).toBe("search"); + expect(parsed.graphqlQuery).toContain("query UnifiedSearch("); + expect(parsed.graphqlQuery).toContain("filters: $filters"); + expect(parsed.variables).toEqual({ + targets: [{ registry: "NPM", name: "express" }], + query: "router middleware secret text", + allowPartialResults: false, + waitTimeoutMs: 20_000, + }); + stderrSpy.mockRestore(); + }); + it("throws CodeNavigationIndexingError for data-path INDEXING sentinel on grepRepo", async () => { mockFetch(() => Promise.resolve( diff --git a/src/services/code-navigation-service.ts b/src/services/code-navigation-service.ts index ee2371e0..6c7977af 100644 --- a/src/services/code-navigation-service.ts +++ b/src/services/code-navigation-service.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { debugLog, isDebugAreaEnabled } from "../shared/debug-log.js"; import { type PkgseerGraphqlResponse, PkgseerTransportError, @@ -830,6 +831,127 @@ query UnifiedSearchStatus($searchRef: String!, $includeResults: Boolean!) { } }`; +function debugUnifiedSearchRequest(variables: { + targets: Array>; + query: string; + sources?: UnifiedSearchSource[]; + filters?: UnifiedSearchFilters; + allowPartialResults: boolean; + limit?: number; + offset?: number; + waitTimeoutMs?: number; +}): void { + if (!isDebugAreaEnabled("code-nav")) return; + const serialised = serialiseForDebug(variables); + const filters = asRecord(serialised.filters); + + debugLog("code-nav", { + event: "request", + operation: "search", + targetCount: Array.isArray(serialised.targets) + ? serialised.targets.length + : 0, + sources: Array.isArray(serialised.sources) ? serialised.sources : [], + hasFilters: filters !== undefined, + filterKeys: filters ? Object.keys(filters).sort() : [], + fileIntent: + filters && typeof filters.fileIntent === "string" + ? filters.fileIntent + : "omitted", + allowPartialResults: serialised.allowPartialResults === true, + presentVariableKeys: Object.keys(serialised).sort(), + hasLimit: typeof serialised.limit === "number", + hasOffset: typeof serialised.offset === "number", + waitTimeoutMs: + typeof serialised.waitTimeoutMs === "number" + ? serialised.waitTimeoutMs + : undefined, + }); +} + +function debugGraphqlWireRequest( + operation: "search" | "searchSymbols", + graphqlQuery: string, + variables: Record, +): void { + if (!isDebugAreaEnabled("code-nav-wire")) return; + debugLog("code-nav-wire", { + event: "wire-request", + operation, + graphqlQuery, + variables: serialiseForDebug(variables), + }); +} + +function debugSearchSymbolsRequest(variables: { + registry?: string; + packageName?: string; + repoUrl?: string; + gitRef?: string; + query?: string; + keywords?: string[]; + matchMode?: SearchSymbolsMatchMode; + kind?: SearchSymbolsKind; + category?: SymbolCategory; + filePath?: string; + version?: string; + limit?: number; + fileIntent?: SearchSymbolsFileIntent; + waitTimeoutMs?: number; +}): void { + if (!isDebugAreaEnabled("code-nav")) return; + const serialised = serialiseForDebug(variables); + + debugLog("code-nav", { + event: "request", + operation: "searchSymbols", + targetType: typeof serialised.repoUrl === "string" ? "repo" : "package", + queryMode: Array.isArray(serialised.keywords) + ? typeof serialised.query === "string" + ? "query+keywords" + : "keywords" + : typeof serialised.query === "string" + ? "query" + : "none", + keywordsCount: Array.isArray(serialised.keywords) + ? serialised.keywords.length + : 0, + kind: typeof serialised.kind === "string" ? serialised.kind : undefined, + category: + typeof serialised.category === "string" ? serialised.category : undefined, + fileIntent: + typeof serialised.fileIntent === "string" + ? serialised.fileIntent + : "omitted", + presentVariableKeys: Object.keys(serialised).sort(), + hasLimit: typeof serialised.limit === "number", + waitTimeoutMs: + typeof serialised.waitTimeoutMs === "number" + ? serialised.waitTimeoutMs + : undefined, + }); +} + +function serialiseForDebug( + value: Record, +): Record { + try { + const text = JSON.stringify(value); + if (!text) return {}; + const parsed = JSON.parse(text); + return asRecord(parsed) ?? {}; + } catch { + return {}; + } +} + +function asRecord(value: unknown): Record | undefined { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + const availableVersionSchema = z.object({ version: z.string().nullable().optional(), ref: z.string(), @@ -1432,27 +1554,30 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { } let response: PkgseerGraphqlResponse; + const variables = { + targets: params.targets.map((target) => ({ + registry: target.registry, + name: target.packageName, + version: target.version, + repoUrl: target.repoUrl, + gitRef: target.gitRef, + })), + query: params.query, + sources: params.sources, + filters: params.filters, + allowPartialResults: params.allowPartialResults ?? false, + limit: params.limit, + offset: params.offset, + waitTimeoutMs: params.waitTimeoutMs, + }; + debugUnifiedSearchRequest(variables); + debugGraphqlWireRequest("search", UNIFIED_SEARCH_QUERY, variables); try { response = await postPkgseerGraphql({ endpointUrl: this.codeNavigationUrl, token, query: UNIFIED_SEARCH_QUERY, - variables: { - targets: params.targets.map((target) => ({ - registry: target.registry, - name: target.packageName, - version: target.version, - repoUrl: target.repoUrl, - gitRef: target.gitRef, - })), - query: params.query, - sources: params.sources, - filters: params.filters, - allowPartialResults: params.allowPartialResults ?? false, - limit: params.limit, - offset: params.offset, - waitTimeoutMs: params.waitTimeoutMs, - }, + variables, fetchFn: this.fetchFn, }); } catch (cause) { @@ -1577,27 +1702,30 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { } let response: PkgseerGraphqlResponse; + const variables = { + registry: params.target.registry, + packageName: params.target.packageName, + repoUrl: params.target.repoUrl, + gitRef: params.target.gitRef, + query: params.query, + keywords: params.keywords, + matchMode: params.matchMode, + kind: params.kind, + category: params.category, + filePath: params.filePath, + version: params.target.version, + limit: params.limit, + fileIntent: params.fileIntent, + waitTimeoutMs: params.waitTimeoutMs, + }; + debugSearchSymbolsRequest(variables); + debugGraphqlWireRequest("searchSymbols", SEARCH_SYMBOLS_QUERY, variables); try { response = await postPkgseerGraphql({ endpointUrl: this.codeNavigationUrl, token, query: SEARCH_SYMBOLS_QUERY, - variables: { - registry: params.target.registry, - packageName: params.target.packageName, - repoUrl: params.target.repoUrl, - gitRef: params.target.gitRef, - query: params.query, - keywords: params.keywords, - matchMode: params.matchMode, - kind: params.kind, - category: params.category, - filePath: params.filePath, - version: params.target.version, - limit: params.limit, - fileIntent: params.fileIntent, - waitTimeoutMs: params.waitTimeoutMs, - }, + variables, fetchFn: this.fetchFn, }); } catch (cause) { diff --git a/src/shared/code-navigation-defaults.ts b/src/shared/code-navigation-defaults.ts index 303e8cc0..748d5d5b 100644 --- a/src/shared/code-navigation-defaults.ts +++ b/src/shared/code-navigation-defaults.ts @@ -1,5 +1,3 @@ -import type { SearchSymbolsFileIntent } from "../services/index.js"; - /** * Default indexing wait time for any code-navigation request issued * by the CLI or MCP surfaces. Both surfaces import this so defaults @@ -19,21 +17,11 @@ export const DEFAULT_WAIT_TIMEOUT_MS = 20_000; */ export const MAX_WAIT_TIMEOUT_MS = 60_000; -/** - * Tool-scoped default: when a caller omits `file_intent` on the - * `search_symbols` surface, apply this filter so top results are - * production source rather than tests/benchmarks/examples. Named with - * a `SEARCH_SYMBOLS_` prefix so tool #2 can declare its own defaults - * alongside without naming churn. - */ -export const SEARCH_SYMBOLS_DEFAULT_FILE_INTENT: SearchSymbolsFileIntent = - "PRODUCTION"; - /** * Sentinel: the caller explicitly asked for "all intents" (CLI * `--intent all`, MCP `file_intent: "all"`). Distinct from `undefined` - * (which means "caller did not set the field") so the service - * translation layer can make an intentional choice rather than guess. + * (which means "caller omitted the field") so the shared builders can + * preserve that choice without guessing. * * Translates to "omit the GraphQL variable" at the service layer, * where omission returns results from every file intent (confirmed @@ -43,10 +31,9 @@ export const FILE_INTENT_ALL = Symbol("FILE_INTENT_ALL"); /** * User-facing file_intent input: either a specific intent, the - * `FILE_INTENT_ALL` sentinel, or `undefined` (no intent set — callers - * should default this at the entry point). + * `FILE_INTENT_ALL` sentinel, or `undefined` (no filter requested). */ export type FileIntentInput = - | SearchSymbolsFileIntent + | import("../services/index.js").SearchSymbolsFileIntent | typeof FILE_INTENT_ALL | undefined; diff --git a/src/shared/debug-log.test.ts b/src/shared/debug-log.test.ts index 2c6ff095..2853e906 100644 --- a/src/shared/debug-log.test.ts +++ b/src/shared/debug-log.test.ts @@ -52,7 +52,7 @@ describe("debugLog", () => { expect(stderrSpy).not.toHaveBeenCalled(); }); - it("emits for any area when GITHITS_DEBUG=*", () => { + it("emits for non-sensitive areas when GITHITS_DEBUG=*", () => { process.env.GITHITS_DEBUG = "*"; debugLog("code-nav", {}); debugLog("auth", {}); @@ -60,6 +60,18 @@ describe("debugLog", () => { expect(stderrSpy).toHaveBeenCalledTimes(3); }); + it("does not enable explicit-only wire areas when GITHITS_DEBUG=*", () => { + process.env.GITHITS_DEBUG = "*"; + debugLog("code-nav-wire", { foo: "bar" }); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("still emits for explicit-only wire areas when named directly", () => { + process.env.GITHITS_DEBUG = "code-nav-wire"; + debugLog("code-nav-wire", { foo: "bar" }); + expect(stderrSpy).toHaveBeenCalledTimes(1); + }); + it("serialises payload contents as JSON on a single line", () => { process.env.GITHITS_DEBUG = "code-nav"; debugLog("code-nav", { diff --git a/src/shared/debug-log.ts b/src/shared/debug-log.ts index 9c6703b0..f74012ec 100644 --- a/src/shared/debug-log.ts +++ b/src/shared/debug-log.ts @@ -2,20 +2,25 @@ * Diagnostic logging gated by `GITHITS_DEBUG`. * * Emits a single line of JSON to stderr when the caller's `area` - * matches the env-var scope. Intended for error-path instrumentation - * so real-world failures of the new typed-error mapping are - * diagnosable without asking users to run with a flag we added after - * the fact. + * matches the env-var scope. This is the shared diagnostics path for + * service integrations that need low-friction field debugging without + * inventing per-feature flags. * - * **PII policy.** Debug payloads carry error codes, parsed spec + * **PII policy.** Most debug payloads carry error codes, parsed spec * shape, and request parameter *names* — never the user's query * text, bearer tokens, response bodies, or any other caller-owned - * content. Callers are responsible for filtering before passing - * payloads here; this module does not inspect payload contents. + * content. The one explicit exception is the opt-in `code-nav-wire` + * scope, which logs the exact GraphQL document and serialised + * variables for code-navigation requests. Callers are responsible for + * filtering before passing payloads here; this module does not inspect + * payload contents. * * **Scope syntax.** - * - `GITHITS_DEBUG=*` enables every area. - * - `GITHITS_DEBUG=code-nav` scopes to the `code-nav` area. + * - `GITHITS_DEBUG=*` enables every non-sensitive area. + * - `GITHITS_DEBUG=code-nav` scopes to the safe code-navigation area. + * - `GITHITS_DEBUG=code-nav-wire` logs exact GraphQL + variables for + * code-navigation requests. It is explicit-only and is not enabled + * by `*`. * - `GITHITS_DEBUG=code-nav,auth` comma-separated list. * - Unset or empty: no output. */ @@ -46,14 +51,24 @@ export function debugLog(area: string, payload: Record): void { process.stderr.write(`${text}\n`); } +export function isDebugAreaEnabled(area: string): boolean { + return isAreaEnabled(area); +} + function isAreaEnabled(area: string): boolean { const raw = process.env.GITHITS_DEBUG; if (!raw || raw === "") return false; - if (raw === "*") return true; const scopes = raw .split(",") .map((s) => s.trim()) .filter(Boolean); - return scopes.includes(area) || scopes.includes("*"); + + if (scopes.includes(area)) return true; + if (isExplicitOnlyArea(area)) return false; + return scopes.includes("*"); +} + +function isExplicitOnlyArea(area: string): boolean { + return area === "code-nav-wire"; } diff --git a/src/shared/index.ts b/src/shared/index.ts index b46a9852..3a6c4d40 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -15,7 +15,6 @@ export { FILE_INTENT_ALL, type FileIntentInput, MAX_WAIT_TIMEOUT_MS, - SEARCH_SYMBOLS_DEFAULT_FILE_INTENT, } from "./code-navigation-defaults.js"; export { type MappedError, diff --git a/src/shared/search-symbols-request.test.ts b/src/shared/search-symbols-request.test.ts index edd56830..68cea716 100644 --- a/src/shared/search-symbols-request.test.ts +++ b/src/shared/search-symbols-request.test.ts @@ -5,14 +5,14 @@ import { buildSearchSymbolsParams } from "./search-symbols-request.js"; describe("buildSearchSymbolsParams", () => { const baseTarget = { registry: "NPM", packageName: "express" } as const; - it("defaults fileIntent to PRODUCTION and records it as defaulted", () => { + it("leaves fileIntent unset when the caller omits it", () => { const { params, defaulted } = buildSearchSymbolsParams({ target: baseTarget, query: "middleware", }); - expect(params.fileIntent).toBe("PRODUCTION"); - expect(defaulted).toContain("fileIntent"); + expect(params.fileIntent).toBeUndefined(); + expect(defaulted).not.toContain("fileIntent"); }); it("defaults waitTimeoutMs to 20000 and records it as defaulted", () => { @@ -79,7 +79,7 @@ describe("buildSearchSymbolsParams", () => { kind: "FUNCTION", filePath: "lib/", limit: 25, - fileIntent: "PRODUCTION", + fileIntent: undefined, waitTimeoutMs: 3000, }); }); diff --git a/src/shared/search-symbols-request.ts b/src/shared/search-symbols-request.ts index 3f3d829b..f96a660f 100644 --- a/src/shared/search-symbols-request.ts +++ b/src/shared/search-symbols-request.ts @@ -10,7 +10,6 @@ import { DEFAULT_WAIT_TIMEOUT_MS, FILE_INTENT_ALL, type FileIntentInput, - SEARCH_SYMBOLS_DEFAULT_FILE_INTENT, } from "./code-navigation-defaults.js"; /** @@ -21,8 +20,8 @@ import { * translation. * * `fileIntent` is tri-valued: specific intent, `FILE_INTENT_ALL` - * (caller asked for all), or `undefined` (caller omitted the field — - * the builder fills in the default). + * (caller asked for all), or `undefined` (caller omitted the field and + * the request should carry no file-intent filter). */ export interface SearchSymbolsRequestInput { target: CodeNavigationTarget; @@ -46,14 +45,14 @@ export interface SearchSymbolsRequestInput { */ export interface SearchSymbolsRequestBuildResult { params: SearchSymbolsParams; - defaulted: ReadonlyArray<"fileIntent" | "waitTimeoutMs">; + defaulted: ReadonlyArray<"waitTimeoutMs">; } /** * Build a `SearchSymbolsParams` object from caller-facing input. * * Responsibilities: - * - Fill in defaults for `fileIntent` and `waitTimeoutMs`. + * - Fill in the default for `waitTimeoutMs`. * - Translate the `FILE_INTENT_ALL` sentinel to "omit the GraphQL * variable" by leaving `fileIntent: undefined` on the outgoing * params — omission returns all intents on the live backend. @@ -65,9 +64,9 @@ export interface SearchSymbolsRequestBuildResult { export function buildSearchSymbolsParams( input: SearchSymbolsRequestInput, ): SearchSymbolsRequestBuildResult { - const defaulted: Array<"fileIntent" | "waitTimeoutMs"> = []; + const defaulted: Array<"waitTimeoutMs"> = []; - const resolvedFileIntent = resolveFileIntent(input.fileIntent, defaulted); + const resolvedFileIntent = resolveFileIntent(input.fileIntent); const resolvedWaitTimeoutMs = resolveWaitTimeoutMs( input.waitTimeoutMs, defaulted, @@ -92,22 +91,17 @@ export function buildSearchSymbolsParams( function resolveFileIntent( input: FileIntentInput, - defaulted: Array<"fileIntent" | "waitTimeoutMs">, ): SearchSymbolsFileIntent | undefined { if (input === FILE_INTENT_ALL) { // Caller asked for all intents; send no filter to the backend. return undefined; } - if (input === undefined) { - defaulted.push("fileIntent"); - return SEARCH_SYMBOLS_DEFAULT_FILE_INTENT; - } return input; } function resolveWaitTimeoutMs( input: number | undefined, - defaulted: Array<"fileIntent" | "waitTimeoutMs">, + defaulted: Array<"waitTimeoutMs">, ): number { if (input === undefined) { defaulted.push("waitTimeoutMs"); diff --git a/src/shared/search-symbols-response.ts b/src/shared/search-symbols-response.ts index d4b55cf1..db1e0197 100644 --- a/src/shared/search-symbols-response.ts +++ b/src/shared/search-symbols-response.ts @@ -48,7 +48,7 @@ export interface SearchSymbolsQueryEcho { fileIntent: string; // lowercase enum value or the literal "all" limit?: number; waitTimeoutMs: number; - defaulted: ReadonlyArray<"fileIntent" | "waitTimeoutMs">; + defaulted: ReadonlyArray<"waitTimeoutMs">; } export interface SearchSymbolsErrorPayload { @@ -73,7 +73,7 @@ export interface SearchSymbolsErrorPayload { */ export function buildSearchSymbolsSuccessPayload( params: SearchSymbolsParams, - defaulted: ReadonlyArray<"fileIntent" | "waitTimeoutMs">, + defaulted: ReadonlyArray<"waitTimeoutMs">, result: SearchSymbolsResult, ): SearchSymbolsSuccessPayload { const payload: SearchSymbolsSuccessPayload = { @@ -141,10 +141,10 @@ export function buildSearchSymbolsErrorPayload( } function echoFileIntent(resolved: SearchSymbolsParams["fileIntent"]): string { - // When the builder translated `FILE_INTENT_ALL` to "omit the - // GraphQL variable", `params.fileIntent` is `undefined`. Echo that - // as the literal "all" so the agent/user sees the intent that was - // actually applied. + // Omitted file intent means "search across all intents". Keep + // echoing that as the literal "all" so the legacy JSON contract + // stays stable whether the caller explicitly chose the alias or just + // omitted the filter. if (resolved === undefined) return "all"; return resolved.toLowerCase(); } diff --git a/src/shared/unified-search-request.test.ts b/src/shared/unified-search-request.test.ts index 449a8cad..ac809e25 100644 --- a/src/shared/unified-search-request.test.ts +++ b/src/shared/unified-search-request.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { buildUnifiedSearchParams } from "./unified-search-request.js"; describe("buildUnifiedSearchParams", () => { - it("applies defaults and keeps raw query when no structured qualifiers", () => { + it("applies numeric defaults and keeps raw query when no structured qualifiers", () => { const built = buildUnifiedSearchParams({ target: { registry: "NPM", packageName: "express" }, query: "router middleware", @@ -10,16 +10,11 @@ describe("buildUnifiedSearchParams", () => { expect(built.rawQuery).toBe("router middleware"); expect(built.compiledQuery).toBe("router middleware"); - expect(built.params.filters).toEqual({ fileIntent: "PRODUCTION" }); + expect(built.params.filters).toBeUndefined(); expect(built.params.limit).toBe(20); expect(built.params.offset).toBe(0); expect(built.params.waitTimeoutMs).toBe(20_000); - expect(built.defaulted).toEqual([ - "limit", - "offset", - "waitTimeoutMs", - "fileIntent", - ]); + expect(built.defaulted).toEqual(["limit", "offset", "waitTimeoutMs"]); }); it("does not override explicit fileIntent", () => { @@ -33,7 +28,7 @@ describe("buildUnifiedSearchParams", () => { expect(built.defaulted).not.toContain("fileIntent"); }); - it("does not default fileIntent for explicit docs-only searches", () => { + it("leaves fileIntent unset for explicit docs-only searches", () => { const built = buildUnifiedSearchParams({ target: { registry: "NPM", packageName: "express" }, query: "routing", @@ -41,18 +36,18 @@ describe("buildUnifiedSearchParams", () => { }); expect(built.params.filters).toBeUndefined(); - expect(built.defaulted).not.toContain("fileIntent"); + expect(built.defaulted).toEqual(["limit", "offset", "waitTimeoutMs"]); }); - it("defaults fileIntent when selected sources include code search", () => { + it("does not invent fileIntent when selected sources include code search", () => { const built = buildUnifiedSearchParams({ target: { registry: "NPM", packageName: "express" }, query: "routing", sources: ["DOCS", "CODE"], }); - expect(built.params.filters).toEqual({ fileIntent: "PRODUCTION" }); - expect(built.defaulted).toContain("fileIntent"); + expect(built.params.filters).toBeUndefined(); + expect(built.defaulted).toEqual(["limit", "offset", "waitTimeoutMs"]); }); it("compiles structured name and language into AND-ed query qualifiers", () => { diff --git a/src/shared/unified-search-request.ts b/src/shared/unified-search-request.ts index 2a1bd6fb..5a420857 100644 --- a/src/shared/unified-search-request.ts +++ b/src/shared/unified-search-request.ts @@ -7,10 +7,7 @@ import type { UnifiedSearchParams, UnifiedSearchSource, } from "../services/index.js"; -import { - DEFAULT_WAIT_TIMEOUT_MS, - SEARCH_SYMBOLS_DEFAULT_FILE_INTENT, -} from "./code-navigation-defaults.js"; +import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; import { InvalidArgumentError } from "./package-spec.js"; export interface UnifiedSearchRequestInput { @@ -35,7 +32,7 @@ export interface UnifiedSearchRequestBuildResult { params: UnifiedSearchParams; rawQuery: string; compiledQuery: string; - defaulted: ReadonlyArray<"fileIntent" | "limit" | "offset" | "waitTimeoutMs">; + defaulted: ReadonlyArray<"limit" | "offset" | "waitTimeoutMs">; } export function buildUnifiedSearchParams( @@ -43,8 +40,7 @@ export function buildUnifiedSearchParams( ): UnifiedSearchRequestBuildResult { const targets = resolveTargets(input.target, input.targets); const rawQuery = normaliseRequiredQuery(input.query); - const defaulted: Array<"fileIntent" | "limit" | "offset" | "waitTimeoutMs"> = - []; + const defaulted: Array<"limit" | "offset" | "waitTimeoutMs"> = []; const limit = resolveNumber(input.limit, 20, "limit", defaulted); const offset = resolveNumber(input.offset, 0, "offset", defaulted); @@ -65,7 +61,7 @@ export function buildUnifiedSearchParams( kind: input.kind, category: input.category, pathPrefix: input.pathPrefix, - fileIntent: resolveFileIntent(input.fileIntent, input.sources, defaulted), + fileIntent: input.fileIntent, publicOnly: input.publicOnly, }); @@ -133,7 +129,7 @@ function resolveNumber( value: number | undefined, fallback: number, field: "limit" | "offset" | "waitTimeoutMs", - defaulted: Array<"fileIntent" | "limit" | "offset" | "waitTimeoutMs">, + defaulted: Array<"limit" | "offset" | "waitTimeoutMs">, ): number { if (value === undefined) { defaulted.push(field); @@ -142,26 +138,6 @@ function resolveNumber( return value; } -function resolveFileIntent( - value: SearchSymbolsFileIntent | undefined, - sources: UnifiedSearchSource[] | undefined, - defaulted: Array<"fileIntent" | "limit" | "offset" | "waitTimeoutMs">, -): SearchSymbolsFileIntent | undefined { - if (value === undefined) { - if ( - sources && - sources.length > 0 && - sources.every((entry) => entry === "DOCS") - ) { - return undefined; - } - defaulted.push("fileIntent"); - return SEARCH_SYMBOLS_DEFAULT_FILE_INTENT; - } - - return value; -} - function buildQualifierClauses(input: { name?: string; language?: string; diff --git a/src/shared/unified-search-response.test.ts b/src/shared/unified-search-response.test.ts index 089ca46b..87ee963b 100644 --- a/src/shared/unified-search-response.test.ts +++ b/src/shared/unified-search-response.test.ts @@ -11,7 +11,6 @@ describe("buildUnifiedSearchSuccessPayload", () => { const params: UnifiedSearchParams = { targets: [{ registry: "NPM", packageName: "express" }], query: "router middleware", - filters: { fileIntent: "PRODUCTION" }, limit: 20, offset: 0, waitTimeoutMs: 20_000, @@ -22,7 +21,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { params, "router middleware", "router middleware", - ["fileIntent", "limit", "offset", "waitTimeoutMs"], + ["limit", "offset", "waitTimeoutMs"], defaultUnifiedSearchOutcome, ); @@ -50,7 +49,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { params, "router middleware", "router middleware", - ["fileIntent", "limit", "offset", "waitTimeoutMs"], + ["limit", "offset", "waitTimeoutMs"], { state: "incomplete", completed: false, @@ -70,8 +69,8 @@ describe("buildUnifiedSearchSuccessPayload", () => { expect(payload).toEqual({ query: expect.objectContaining({ - filters: expect.objectContaining({ fileIntent: "production" }), - defaulted: ["fileIntent", "limit", "offset", "waitTimeoutMs"], + filters: undefined, + defaulted: ["limit", "offset", "waitTimeoutMs"], }), completed: false, returnedCount: 0, @@ -91,7 +90,7 @@ describe("buildUnifiedSearchSuccessPayload", () => { { ...params, allowPartialResults: true }, "router middleware", "router middleware", - ["fileIntent", "limit", "offset", "waitTimeoutMs"], + ["limit", "offset", "waitTimeoutMs"], { state: "incomplete", completed: false, diff --git a/src/shared/unified-search-response.ts b/src/shared/unified-search-response.ts index 57d86e49..ee07389c 100644 --- a/src/shared/unified-search-response.ts +++ b/src/shared/unified-search-response.ts @@ -24,7 +24,7 @@ export interface UnifiedSearchQueryEcho { limit: number; offset: number; waitTimeoutMs: number; - defaulted: ReadonlyArray<"fileIntent" | "limit" | "offset" | "waitTimeoutMs">; + defaulted: ReadonlyArray<"limit" | "offset" | "waitTimeoutMs">; } export interface UnifiedSearchHitPayload { @@ -116,7 +116,7 @@ export function buildUnifiedSearchSuccessPayload( params: UnifiedSearchParams, rawQuery: string, compiledQuery: string, - defaulted: ReadonlyArray<"fileIntent" | "limit" | "offset" | "waitTimeoutMs">, + defaulted: ReadonlyArray<"limit" | "offset" | "waitTimeoutMs">, outcome: UnifiedSearchOutcome, ): UnifiedSearchCompletedPayload | UnifiedSearchIncompletePayload { const warnings = @@ -229,7 +229,7 @@ function buildQueryEcho( params: UnifiedSearchParams, rawQuery: string, compiledQuery: string, - defaulted: ReadonlyArray<"fileIntent" | "limit" | "offset" | "waitTimeoutMs">, + defaulted: ReadonlyArray<"limit" | "offset" | "waitTimeoutMs">, warnings: string[], ): UnifiedSearchQueryEcho { return { diff --git a/src/tools/search-symbols-parity.test.ts b/src/tools/search-symbols-parity.test.ts index cfd88a73..81692f4e 100644 --- a/src/tools/search-symbols-parity.test.ts +++ b/src/tools/search-symbols-parity.test.ts @@ -210,7 +210,7 @@ describe("search_symbols CLI ↔ MCP JSON parity", () => { })) as { query: { defaulted: string[] } }; expect(cliPayload.query.defaulted).toEqual(mcpPayload.query.defaulted); - expect(cliPayload.query.defaulted).toContain("fileIntent"); + expect(cliPayload.query.defaulted).not.toContain("fileIntent"); expect(cliPayload.query.defaulted).toContain("waitTimeoutMs"); }); diff --git a/src/tools/search-symbols.test.ts b/src/tools/search-symbols.test.ts index 6e479190..0b8eb08a 100644 --- a/src/tools/search-symbols.test.ts +++ b/src/tools/search-symbols.test.ts @@ -11,8 +11,8 @@ describe("searchSymbolsTool", () => { const tool = createSearchSymbolsTool(createMockCodeNavigationService()); expect(tool.name).toBe("search_symbols"); expect(tool.description).toContain("exact-token matches"); - expect(tool.description).toContain("'production'"); - expect(tool.description).toContain("'all'"); + expect(tool.description).toContain("across all intents"); + expect(tool.description).toContain("`all`"); // Category is the preferred filtering surface per the April 2026 // backend taxonomy split. expect(tool.description).toContain("category"); @@ -45,7 +45,7 @@ describe("searchSymbolsTool", () => { kind: "FUNCTION", filePath: undefined, limit: 10, - fileIntent: "PRODUCTION", + fileIntent: undefined, waitTimeoutMs: 5000, }); }); @@ -74,8 +74,8 @@ describe("searchSymbolsTool", () => { version: undefined, }); expect(payload.query.query).toBe("middleware"); - expect(payload.query.fileIntent).toBe("production"); - expect(payload.query.defaulted).toContain("fileIntent"); + expect(payload.query.fileIntent).toBe("all"); + expect(payload.query.defaulted).not.toContain("fileIntent"); expect(payload.query.defaulted).toContain("waitTimeoutMs"); // No underscore-prefixed keys in the shared envelope. expect(payload._warning).toBeUndefined(); diff --git a/src/tools/search-symbols.ts b/src/tools/search-symbols.ts index 6948aff2..75893aaf 100644 --- a/src/tools/search-symbols.ts +++ b/src/tools/search-symbols.ts @@ -157,7 +157,7 @@ const schema = { ]) .optional() .describe( - "File intent filter. Defaults to 'production' so top results are production source. Pass 'all' to include tests, benchmarks, examples, and other non-production files.", + "Optional file intent filter. Omit it to search across all intents. `all` remains accepted as an explicit no-filter alias.", ), wait_timeout_ms: z.coerce .number() @@ -174,7 +174,7 @@ const DESCRIPTION = "Search a dependency's source code by exact-token matches. Use for symbol lookup, not natural-language questions. " + 'Prefer unified `search` with `sources:["symbol"]` for new symbol-shaped workflows; use `search_symbols` when you specifically need this dedicated legacy contract. ' + "Package scope: pass target.registry + target.package_name. Repo scope: pass target.repo_url + target.git_ref. " + - "`file_intent` defaults to 'production' so top results are production source; pass 'all' to include tests, examples, benchmarks. " + + "Omit `file_intent` to search across all intents; `all` remains accepted as an explicit no-filter alias. " + "`query` and `keywords` can combine; `match_mode` controls AND vs OR across keywords. " + "Filter by `category` (broad: callable/type/module/data/documentation — preferred) or `kind` (precise construct like trait/record/namespace). " + "Prefer `file_path` to scope to a directory (e.g., 'src/'). " + diff --git a/src/tools/search.ts b/src/tools/search.ts index eb865f3a..0a434860 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -136,7 +136,7 @@ const schema = { ]) .optional() .describe( - "Optional file-intent filter. When omitted, AUTO/code/symbol searches default to production; explicit docs-only searches omit the filter because docs do not support it.", + "Optional file-intent filter. Omit it to search across all intents; some sources may ignore this filter and report that in sourceStatus.", ), public_only: z.boolean().optional(), name: z.string().optional(),