From 93a9b705b574a6d5c25201c30f23d3a43bb1d705 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 3 Aug 2026 14:40:02 +0300 Subject: [PATCH 1/8] feat: add target resolution service Introduce the internal GraphQL client with compact and detailed field modes. Reuse package intelligence error classification without changing public MCP service interfaces. --- docs/plans/resolve-target.md | 360 ++++++++++++++++++ packages/core-internal/src/index.ts | 1 + .../services/package-intelligence-service.ts | 286 +++++++------- .../services/resolve-target-service.test.ts | 339 +++++++++++++++++ .../src/services/resolve-target-service.ts | 317 +++++++++++++++ 5 files changed, 1172 insertions(+), 131 deletions(-) create mode 100644 docs/plans/resolve-target.md create mode 100644 packages/core-internal/src/services/resolve-target-service.test.ts create mode 100644 packages/core-internal/src/services/resolve-target-service.ts diff --git a/docs/plans/resolve-target.md b/docs/plans/resolve-target.md new file mode 100644 index 00000000..f8776a8c --- /dev/null +++ b/docs/plans/resolve-target.md @@ -0,0 +1,360 @@ +# Plan: `resolve` CLI dogfood surface + +## Goal + +Add `githits resolve ` so we can test backend target resolution from a +normal local/source CLI build. Phase 1 is CLI-only; it establishes the request, +JSON, error, and ranking-language contracts that the later MCP tool will reuse. + +**Assumption:** "internally test" means the command may be implemented and +dogfooded from this branch/local builds, but must not be included in a published +CLI version until the release gates below pass. No hidden command or client-side +feature flag is added. + +## Verified baseline + +Verified 2026-08-03 against: + +- CLI repo `479b276` +- backend `4fbebb1975e614ffd1c21cb4cfc6eafbe77d6e27` + +Backend contract: + +- `resolveTarget(name!, query, registries, preferredKinds, intentHints, limit)` + returns `best`, ranked `candidates`, unbounded `protectedMatches`, `ambiguous`, + and `ambiguousReason`. Default limit is 8; accepted range is 1-20. +- Candidate non-null fields: `kind`, `canonicalKey`, `displayName`, + `matchedAliases`, `docsAvailable`, `codeAvailable`, `protected`, `matchTier`, + `score`, and `confidence`. Nullable fields: `description`, `registry`, + `packageName`, `latestVersion`, `repositoryUrl`, `repositoryOwner`, + `repositoryName`, `stars`, `downloadsLastMonth`, `downloadsTotal`, + `documentationUrl`, and `reason`. +- Kinds are `PACKAGE | REPOSITORY`; confidence is + `EXACT | HIGH | MEDIUM | LOW`; ambiguity reasons are `NOT_AMBIGUOUS`, + `DUPLICATE_EXACT_NAME`, `CLOSE_CANDIDATES`, and `LOW_CONFIDENCE`. +- The ranker deduplicates candidates by `{kind, canonicalKey}`. `best` is null + only for no candidates. Protected matches come from the same candidate + population but are not bounded by `limit`, so terminal sections must remove + cross-list overlap. +- `inspection` is lazy and expensive. This feature never selects it. +- The authenticated GraphQL field is enabled by default and can be disabled by + the backend `graphql_enabled` kill switch. Fuzzy retrieval separately has the + `TARGET_RESOLUTION_FUZZY_ENABLED` runtime control. `FEATURE_FLAG_REQUIRED` + already maps to `ACCESS_DENIED` in the client. +- The current backend corpus has 11 cases (6 unique names) and no + ambiguous-expectation case. Existing + backend guidance requires quality, fuzzy-latency, and rate-limit review before + advertising this resolver. + +Client constraints: + +- `PackageIntelligenceService` is public through `@githits/mcp/client` and + `McpToolServices`; adding a required method would be a public MCP API change. +- `postPkgseerGraphql` handles only one HTTP request. HTTP, GraphQL, transport, + schema-mismatch, and token-refresh handling currently lives inside + `PackageIntelligenceServiceImpl` and cannot simply be reused as the old plan + claimed. +- The existing `TargetResolution` type/module describes index freshness, not + fuzzy target discovery. New APIs use `ResolveTarget*` names. + +## Scope and release gate + +Implementation and branch-local dogfooding may proceed now. Do not merge the +implementation to `main`, bump the root package version, or publish a CLI +containing `resolve` until all of these are true: + +1. `mix target.smoke --env prod` passes an expanded corpus covering exact names, + curated aliases, duplicate exact names, close candidates, and low confidence. +2. No known exact-package case resolves to the wrong `best`; ambiguity wording + is useful in manual dogfooding. +3. The backend team revalidates fuzzy latency against expected CLI/MCP volume, + optimizes it, or explicitly launches with fuzzy retrieval disabled. +4. Backend rate limiting and GraphQL complexity are confirmed adequate for the + expected call volume. + +Resolver defects found during dogfooding go in the findings log at the end of +this file with exact input, hints, expected result, actual result, and date. +Move each finding into the backend `cases.json` corpus, then remove its log +entry. The client PR does not add another quality-eval harness. + +## Product decisions + +1. Add an always-registered top-level command: `githits resolve `. + Resolution spans packages and GitHub repositories, so it does not belong + under `pkg`. +2. Add an internal-only `ResolveTargetService`; do not modify + `PackageIntelligenceService`, `McpToolServices`, `packages/mcp/src/index.ts`, + or `packages/mcp/src/client.ts` in Phase 1. +3. Extract the existing package-intelligence HTTP/GraphQL/transport classifier + methods into package-local reusable functions and use them from both service + implementations. Duplicating the classifier was rejected because auth, + schema-drift, client-update, and retry behavior would diverge. +4. Fetch a compact field set for terminal output and conditionally select + diagnostic fields for `--json`. No `inspection`, separate query, or future- + only field is selected. +5. Define the compact structured envelope now and keep it for MCP parity. Do not + ship a temporary raw-backend JSON shape that Phase 2 knowingly breaks. +6. Keep one compact text mode; no `--verbose`. `--json` is the diagnostic and + machine-readable mode. +7. Keep all ranking inputs needed for dogfooding. Use `--intent-hint` rather + than vague `--hint`; use `--prefer-kind` because `search --kind` is a hard + filter while this is a soft preference. +8. No candidates means resolution failed: print a valid result, set + `process.exitCode = 1`, and let stdout flush. Other failures use the existing + mapped error exits. + +## CLI contract + +```text +githits resolve [options] + +Arguments: + name package or GitHub repository name + +Options: + -q, --query task context used as a soft ranking hint + --registry comma-separated package registries + --prefer-kind soft preference: package | repository + --intent-hint soft intent hint (repeatable) + -n, --limit ranked candidates (1-20, default 8); + protected exact matches may be additional + --json emit structured diagnostic JSON +``` + +Command help must state that `--query` is sent to the service and must not +contain credentials, personal data, private code, or proprietary content. + +Normalization and validation, in `buildResolveTargetParams`: + +- Trim `name`; reject empty with `INVALID_ARGUMENT`. +- Trim `query`; omit an empty value. +- Parse registry CSV case-insensitively, trim entries, drop empty entries, + validate against `PKGSEER_REGISTRY_ARGS`, deduplicate by first occurrence, + and omit an empty result. +- Normalize `prefer-kind` case-insensitively and send it as a one-element + `preferredKinds` array; reject unknown values. +- Trim intent hints, drop empty values, and case-insensitively deduplicate while + preserving the first spelling and order. +- Map validated registries through `toPkgseerRegistry`; map `prefer-kind` to the + GraphQL `PACKAGE | REPOSITORY` enum before constructing service params. +- Parse CLI limit lexically with `parseIntCliOption`; independently require an + integer from 1-20 in the shared builder. Apply the shared default of 8 there. +- Omit all unset optional resolver variables. The client always sends its + explicit default `limit: 8` plus the query-only `includeDetailedFields` flag. + +Do not use Commander `.choices()` for validated options: action-level +validation must preserve the standard terminal/JSON error envelopes. + +## Wire contract + +`RESOLVE_TARGET_QUERY` uses one candidate fragment across `best`, +`protectedMatches`, and `candidates`. + +Always select: + +```text +kind canonicalKey displayName description registry stars downloadsLastMonth +docsAvailable codeAvailable protected confidence +``` + +Select only when `includeDetailedFields` is true: + +```text +packageName latestVersion repositoryUrl repositoryOwner repositoryName +downloadsTotal documentationUrl matchedAliases matchTier score reason +``` + +Never select `inspection`. Use mode-specific candidate schemas: always-selected +non-null fields are required in both modes; conditionally selected non-null +fields are required only in detailed mode. Model enum-like response fields as +`z.string()` so a new backend enum value remains parseable. The formatter +narrows known values and uses safe generic wording for unknown values. Missing +or wrongly typed fields required for the active mode are +`MalformedPackageIntelligenceResponseError`. + +Service flow: + +```text +resolveTarget(params) + -> withTelemetrySpan("resolve-target.request") + -> executeWithTokenRefresh(... AuthenticationError ...) + -> postPkgseerGraphql(...) + -> shared package-intelligence response/error classifiers + -> Zod response parsing +``` + +## Output contract + +Default terminal output is compact and scannable: + +```text +Best: npm:express [exact] · package · 66k stars · 89M downloads/mo · docs · code + Fast, unopinionated, minimalist web framework + +Also consider: + github:expressjs/express [high] · repository + npm:express-validator [medium] · package + +Next: githits search '' --in npm:express +``` + +Rules: + +- Use `Best` only for non-ambiguous `EXACT`/`HIGH`; otherwise use `Top`. +- If ambiguous, print one plain-language line before the result. Give specific + guidance for duplicate exact names (`--registry`), close candidates, and low + confidence; unknown reasons get neutral generic wording. +- Render protected matches excluding `best` in `Protected exact-name matches`. + Render other ranked candidates excluding `best` and all protected keys in + `Also consider`. Preserve backend order and first occurrence. +- Show one normalized, single-line best description capped at 120 characters. + Alternative rows do not repeat descriptions. +- Reuse `formatCompactNumber`, colors, `shellQuote`, and canonical keys. If + `--query` was supplied, the `Next` command uses it; otherwise it contains the + literal `` placeholder. Repository and package targets use the same + valid `search --in` follow-up. +- In text mode, no candidates prints `No targets found for ''.`; in JSON + mode, emit the empty envelope below. Both exit 1. + +`--json` emits a stable, camelCase envelope. Nullable fields are omitted; enum +values are lowercase. Build `candidates` from backend ranked candidates followed +by protected matches absent from that list, preserving each source order and +deduplicating by `{kind, canonicalKey}`. Candidate objects occur only once; +every `best` and `protectedMatches` canonical-key reference resolves to one +candidate object: + +```json +{ + "best": "npm:express", + "ambiguous": false, + "candidates": [ + { + "target": "npm:express", + "name": "express", + "kind": "package", + "confidence": "exact", + "description": "Fast, unopinionated, minimalist web framework", + "registry": "npm", + "latestVersion": "5.1.0", + "stars": 66000, + "downloadsLastMonth": 89000000, + "docsAvailable": true, + "codeAvailable": true, + "matchedAliases": ["express"], + "matchTier": 0, + "score": 100, + "reason": "Exact package identity match" + } + ], + "protectedMatches": ["npm:express"] +} +``` + +Emit `ambiguousReason` only when `ambiguous` is true. Empty success is +`{"ambiguous":false,"candidates":[],"protectedMatches":[]}` with exit 1. +JSON errors remain on stderr so stdout is clean. + +## Files + +| Concern | File | +|---|---| +| Reusable existing error classifiers | `packages/core-internal/src/services/package-intelligence-service.ts` | +| Service, query, Zod schemas, params/results | `packages/core-internal/src/services/resolve-target-service.ts` (new) | +| Private core export | `packages/core-internal/src/index.ts` | +| Request/default/validation | `packages/mcp/src/shared/resolve-target-request.ts` (new) | +| JSON projection + terminal formatter | `packages/mcp/src/shared/resolve-target-response.ts` (new) | +| Workspace-only CLI exports | `packages/mcp/src/internal.ts` | +| Container construction in both token branches | `src/container.ts` | +| Command action/registration | `src/commands/resolve.ts` (new), `src/commands/index.ts`, `src/cli.ts` | +| Product docs and smoke | `docs/implementation/cli-commands.md`, `scripts/cli-smoke.ts` | + +Register `resolve` unconditionally with lightweight commands and add it to root +`Getting started` help and `EXPECTED_TOP_LEVEL_COMMANDS`. + +## Tests + +- Core service: exact compact/detailed query selections and variables; prove + `inspection` is absent; optional arguments omitted; detailed fields parse; + malformed required fields fail; HTTP/GraphQL classification reuse; GraphQL + auth refresh; `FEATURE_FLAG_REQUIRED` and validation mapping. +- Existing package-intelligence service: run its focused suite after classifier + extraction to prove no behavior change. +- Request builder: trim/default/empty inputs, registry CSV, dedupe, exact + lowercase-to-GraphQL enum conversion, strict integer/range validation, and + normalized wire params. +- Response/terminal: stable JSON shape, null omission, lowercase/unknown enums, + all ambiguity reasons, best/top wording, protected overlap partitioning, + unbounded protected extras and JSON reference closure, no candidates, + 120-character description, scoped target and quoted-query follow-ups, ANSI + on/off. +- Command: auth before service call, text and JSON success, detailed-mode service + flag, stdout/stderr discipline, mapped errors, no-result `exitCode = 1` + (restored after each test), registration/help/privacy warning. +- CLI smoke: command set; unauthenticated terminal and clean-stdout JSON auth + failures; authenticated success-only text/JSON probes; one all-options probe; + empty-name `INVALID_ARGUMENT`. Do not accept `ACCESS_DENIED` in live mode. +- No MCP parity test or agent eval until the MCP tool exists. + +Verification: + +```text +bun test +bun test +bun run typecheck +bun run format:check +bun run lint +bun run build +(cd packages/mcp && bun run build) +bun run validate:packages:mcp-publish +bun run smoke:cli +bun run smoke:mcp +bun run smoke:cli:built +bun run smoke:mcp:built +``` + +Target size: roughly 1.2-1.5k changed lines including tests and docs. If the +implementation requires a new generic GraphQL executor or exceeds this budget, +stop and re-slice rather than broadening the refactor. + +## Not handling + +- MCP tool/instructions/public service types/version bump: Phase 2 after CLI + dogfooding; it reuses the stable request and JSON contracts above. +- Standalone documentation sites: absent from the backend resolver kind. +- Candidate `inspection`: separate exact-inspection concern. +- Interactive selection, caching, client feature flags, or a second eval + harness: no verified need. +- Verbose terminal mode: diagnostics are already available through `--json`. +- Public release before the backend gates pass. + +## Phase 2 direction + +Add `resolve_target` using the shared request and JSON projection, promote the +smallest required service API through `@githits/mcp`, add parity/smoke coverage, +teach agents when to resolve fuzzy names, and run targeted Claude/Codex agent +evals. Plan that PR from Phase 1 usage rather than expanding this plan now. + +## Resolver findings log + +```text +- [ ] `` (query/registries/preferred kind/intent hints: ) + expected: + actual: +``` + +(No entries yet.) + +## Acceptance criteria + +- Local authenticated `githits resolve` produces the compact partitioned text + output and stable JSON envelope above; no-result and error exits are correct. +- Unauthenticated and invalid-input paths use standard terminal/JSON envelopes + with clean JSON stdout. +- No public MCP API or artifact contains the new internal service. +- All verification commands pass. Live smoke is success-only when credentials + are available; skipped authenticated probes are reported, not represented as + quality validation. +- Durable command/output decisions are copied into + `docs/implementation/cli-commands.md` during implementation. +- After publication gates pass and implementation is complete, dispatch every + findings-log entry to the backend corpus and delete this temporary plan. diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 998c5b1d..338947e4 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -7,6 +7,7 @@ export * from "./services/githits-service.js"; export * from "./services/package-intelligence-service.js"; export * from "./services/promote-version-not-found.js"; export * from "./services/refreshing-githits-service.js"; +export * from "./services/resolve-target-service.js"; export * from "./services/token-provider.js"; export * from "./shared/debug-log.js"; export * from "./shared/fetch-timeout.js"; diff --git a/packages/core-internal/src/services/package-intelligence-service.ts b/packages/core-internal/src/services/package-intelligence-service.ts index 7c66dbc3..6b491ecf 100644 --- a/packages/core-internal/src/services/package-intelligence-service.ts +++ b/packages/core-internal/src/services/package-intelligence-service.ts @@ -2350,144 +2350,19 @@ export class PackageIntelligenceServiceImpl } private createHttpError(response: PkgseerGraphqlResponse): Error { - const status = response.status; - const detail = parseDetail(response.responseBody); - - if (status === 401) { - return new AuthenticationError( - SERVER_AUTHENTICATION_REJECTED_MESSAGE, - "server", - ); - } - - if (status === 403) { - return new PackageIntelligenceAccessError(detail ?? "Access denied."); - } - - if (status >= 500) { - return new PackageIntelligenceBackendError( - detail - ? `Server error (${status}): ${detail}` - : `Server error (${status})`, - status, - ); - } - - return new PackageIntelligenceBackendError( - detail ?? `Request failed with status ${status}`, - status, - ); + return createPackageIntelligenceHttpError(response); } private createTransportError(error: PkgseerTransportError): Error { - if (isFetchTimeoutError(error.cause)) { - return new PackageIntelligenceBackendError( - "Package intelligence request timed out.", - undefined, - "TIMEOUT", - true, - ); - } - return new PackageIntelligenceNetworkError( - "Could not reach the package intelligence service. Check your connection or set GITHITS_CODE_NAV_URL.", - { cause: error }, - ); + return createPackageIntelligenceTransportError(error); } private createGraphQLError( errors: Array>, ): Error { - const message = errors.map((error) => error.message).join(", "); - const extensions = getPrimaryExtensions(errors); - const code = - typeof extensions?.code === "string" ? extensions.code : undefined; - const retryable = - typeof extensions?.retryable === "boolean" - ? extensions.retryable - : undefined; - - if (isClientUpdateRequiredGraphQLError({ message, code })) { - return new ClientUpdateRequiredError( - undefined, - undefined, - this.runtime.clientVersion, - ); - } - - 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": - return new PackageIntelligenceTargetNotFoundError(message); - - case "VERSION_NOT_FOUND": - return new PackageIntelligenceVersionNotFoundError( - message, - typeof extensions?.package === "string" - ? extensions.package - : undefined, - typeof extensions?.requested_version === "string" - ? extensions.requested_version - : undefined, - parseVersionList( - extensions?.available_versions ?? extensions?.availableVersions, - ), - ); - - case "UNSUPPORTED_REGISTRY": - case "VALIDATION_ERROR": - return new PackageIntelligenceValidationError(message); - - case "FEATURE_FLAG_REQUIRED": - return new PackageIntelligenceFeatureFlagRequiredError(message); - - case "UNAUTHORIZED": - return new AuthenticationError( - SERVER_AUTHENTICATION_REJECTED_MESSAGE, - "server", - ); - - case "FORBIDDEN": - return new PackageIntelligenceAccessError( - "Access denied. This feature may not be enabled for your account.", - ); - - case "UPSTREAM_ERROR": - case "TIMEOUT": - case "RATE_LIMITED": - case "INTERNAL_ERROR": - case "UNKNOWN_ERROR": - return new PackageIntelligenceBackendError( - message, - undefined, - code, - retryable, - ); - - default: - break; - } - - return new PackageIntelligenceBackendError( - message, - undefined, - code, - retryable, + return createPackageIntelligenceGraphQLError( + errors, + this.runtime.clientVersion, ); } @@ -3571,6 +3446,155 @@ function stripNullProperties(value: unknown): unknown { return result; } +export interface PackageIntelligenceGraphQLResponseError { + message: string; + extensions?: Record; +} + +/** Shared HTTP classification for clients of the package/source GraphQL API. */ +export function createPackageIntelligenceHttpError( + response: PkgseerGraphqlResponse, +): Error { + const status = response.status; + const detail = parseDetail(response.responseBody); + + if (status === 401) { + return new AuthenticationError( + SERVER_AUTHENTICATION_REJECTED_MESSAGE, + "server", + ); + } + + if (status === 403) { + return new PackageIntelligenceAccessError(detail ?? "Access denied."); + } + + if (status >= 500) { + return new PackageIntelligenceBackendError( + detail + ? `Server error (${status}): ${detail}` + : `Server error (${status})`, + status, + ); + } + + return new PackageIntelligenceBackendError( + detail ?? `Request failed with status ${status}`, + status, + ); +} + +/** Shared transport classification for clients of the package/source API. */ +export function createPackageIntelligenceTransportError( + error: PkgseerTransportError, +): Error { + if (isFetchTimeoutError(error.cause)) { + return new PackageIntelligenceBackendError( + "Package intelligence request timed out.", + undefined, + "TIMEOUT", + true, + ); + } + return new PackageIntelligenceNetworkError( + "Could not reach the package intelligence service. Check your connection or set GITHITS_CODE_NAV_URL.", + { cause: error }, + ); +} + +/** Shared GraphQL classification for clients of the package/source API. */ +export function createPackageIntelligenceGraphQLError( + errors: PackageIntelligenceGraphQLResponseError[], + clientVersion?: string, +): Error { + const message = errors.map((error) => error.message).join(", "); + const extensions = getPrimaryExtensions(errors); + const code = + typeof extensions?.code === "string" ? extensions.code : undefined; + const retryable = + typeof extensions?.retryable === "boolean" + ? extensions.retryable + : undefined; + + if (isClientUpdateRequiredGraphQLError({ message, code })) { + return new ClientUpdateRequiredError(undefined, undefined, clientVersion); + } + + 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": + return new PackageIntelligenceTargetNotFoundError(message); + + case "VERSION_NOT_FOUND": + return new PackageIntelligenceVersionNotFoundError( + message, + typeof extensions?.package === "string" + ? extensions.package + : undefined, + typeof extensions?.requested_version === "string" + ? extensions.requested_version + : undefined, + parseVersionList( + extensions?.available_versions ?? extensions?.availableVersions, + ), + ); + + case "UNSUPPORTED_REGISTRY": + case "VALIDATION_ERROR": + return new PackageIntelligenceValidationError(message); + + case "FEATURE_FLAG_REQUIRED": + return new PackageIntelligenceFeatureFlagRequiredError(message); + + case "UNAUTHORIZED": + return new AuthenticationError( + SERVER_AUTHENTICATION_REJECTED_MESSAGE, + "server", + ); + + case "FORBIDDEN": + return new PackageIntelligenceAccessError( + "Access denied. This feature may not be enabled for your account.", + ); + + case "UPSTREAM_ERROR": + case "TIMEOUT": + case "RATE_LIMITED": + case "INTERNAL_ERROR": + case "UNKNOWN_ERROR": + return new PackageIntelligenceBackendError( + message, + undefined, + code, + retryable, + ); + + default: + return new PackageIntelligenceBackendError( + message, + undefined, + code, + retryable, + ); + } +} + function parseDetail(body: string): string | undefined { if (!body) return undefined; try { @@ -3584,7 +3608,7 @@ function parseDetail(body: string): string | undefined { } function getPrimaryExtensions( - errors: Array>, + errors: PackageIntelligenceGraphQLResponseError[], ): Record | undefined { for (const error of errors) { if (error.extensions && Object.keys(error.extensions).length > 0) { diff --git a/packages/core-internal/src/services/resolve-target-service.test.ts b/packages/core-internal/src/services/resolve-target-service.test.ts new file mode 100644 index 00000000..f2d68e82 --- /dev/null +++ b/packages/core-internal/src/services/resolve-target-service.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it, mock } from "bun:test"; +import { AuthenticationError } from "./githits-service.js"; +import { + MalformedPackageIntelligenceResponseError, + PackageIntelligenceAccessError, + PackageIntelligenceFeatureFlagRequiredError, + PackageIntelligenceValidationError, +} from "./package-intelligence-service.js"; +import { + RESOLVE_TARGET_QUERY, + ResolveTargetServiceImpl, +} from "./resolve-target-service.js"; +import { createMockTokenProvider } from "./test-helpers.js"; + +const ENDPOINT = "https://pkgseer.dev"; + +const COMPACT_CANDIDATE = { + kind: "PACKAGE", + canonicalKey: "npm:express", + displayName: "express", + description: "Fast web framework", + registry: "NPM", + stars: 66_000, + downloadsLastMonth: 89_000_000, + docsAvailable: true, + codeAvailable: true, + protected: true, + confidence: "EXACT", +}; + +const DETAILED_CANDIDATE = { + ...COMPACT_CANDIDATE, + packageName: "express", + latestVersion: "5.1.0", + repositoryUrl: "https://github.com/expressjs/express", + repositoryOwner: "expressjs", + repositoryName: "express", + downloadsTotal: null, + documentationUrl: "https://expressjs.com", + matchedAliases: ["express"], + matchTier: 0, + score: 100, + reason: "Exact package identity match", +}; + +function resultBody(candidate: Record) { + return { + data: { + resolveTarget: { + best: candidate, + protectedMatches: [candidate], + candidates: [candidate], + ambiguous: false, + ambiguousReason: "NOT_AMBIGUOUS", + }, + }, + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function asFetchFn unknown>( + fn: T, +): typeof fetch { + return fn as unknown as typeof fetch; +} + +describe("ResolveTargetServiceImpl", () => { + it("fetches the compact field set with only normalized resolver variables", async () => { + let capturedBody: string | undefined; + const fetchFn = mock((_url: string, init?: RequestInit) => { + capturedBody = init?.body as string; + return Promise.resolve(jsonResponse(resultBody(COMPACT_CANDIDATE))); + }); + const service = new ResolveTargetServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + const result = await service.resolveTarget({ + name: "express", + limit: 8, + includeDetailedFields: false, + }); + + const request = JSON.parse(capturedBody ?? "{}"); + expect(request.variables).toEqual({ + name: "express", + limit: 8, + includeDetailedFields: false, + }); + expect(request.query).toBe(RESOLVE_TARGET_QUERY); + for (const field of [ + "kind", + "canonicalKey", + "displayName", + "description", + "registry", + "stars", + "downloadsLastMonth", + "docsAvailable", + "codeAvailable", + "protected", + "confidence", + ]) { + expect(request.query).toContain(` ${field}\n`); + expect(request.query).not.toContain(`${field} @include`); + } + for (const field of [ + "packageName", + "latestVersion", + "repositoryUrl", + "repositoryOwner", + "repositoryName", + "downloadsTotal", + "documentationUrl", + "matchedAliases", + "matchTier", + "score", + "reason", + ]) { + expect(request.query).toContain( + `${field} @include(if: $includeDetailedFields)`, + ); + } + expect(request.query).not.toContain("inspection"); + expect(result.best).toEqual(COMPACT_CANDIDATE); + }); + + it("fetches and parses detailed fields for JSON output", async () => { + let capturedBody: string | undefined; + const fetchFn = mock((_url: string, init?: RequestInit) => { + capturedBody = init?.body as string; + return Promise.resolve(jsonResponse(resultBody(DETAILED_CANDIDATE))); + }); + const service = new ResolveTargetServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + const result = await service.resolveTarget({ + name: "express", + query: "web framework", + registries: ["NPM"], + preferredKinds: ["PACKAGE"], + intentHints: ["server"], + limit: 3, + includeDetailedFields: true, + }); + + const request = JSON.parse(capturedBody ?? "{}"); + expect(request.variables).toEqual({ + name: "express", + query: "web framework", + registries: ["NPM"], + preferredKinds: ["PACKAGE"], + intentHints: ["server"], + limit: 3, + includeDetailedFields: true, + }); + expect(result.best).toEqual({ + ...DETAILED_CANDIDATE, + downloadsTotal: undefined, + }); + expect(result.best).not.toHaveProperty("downloadsTotal"); + }); + + it("requires detailed non-null fields only in detailed mode", async () => { + const compactService = new ResolveTargetServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn( + mock(() => + Promise.resolve(jsonResponse(resultBody(COMPACT_CANDIDATE))), + ), + ), + ); + await expect( + compactService.resolveTarget({ + name: "express", + limit: 8, + includeDetailedFields: false, + }), + ).resolves.toBeDefined(); + + const detailedService = new ResolveTargetServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn( + mock(() => + Promise.resolve(jsonResponse(resultBody(COMPACT_CANDIDATE))), + ), + ), + ); + await expect( + detailedService.resolveTarget({ + name: "express", + limit: 8, + includeDetailedFields: true, + }), + ).rejects.toBeInstanceOf(MalformedPackageIntelligenceResponseError); + }); + + it("rejects compact responses missing always-selected fields", async () => { + const { confidence: _confidence, ...malformed } = COMPACT_CANDIDATE; + const service = new ResolveTargetServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn( + mock(() => Promise.resolve(jsonResponse(resultBody(malformed)))), + ), + ); + + await expect( + service.resolveTarget({ + name: "express", + limit: 8, + includeDetailedFields: false, + }), + ).rejects.toBeInstanceOf(MalformedPackageIntelligenceResponseError); + }); + + it("refreshes after a GraphQL authentication failure", async () => { + let calls = 0; + const fetchFn = mock(() => { + calls++; + if (calls === 1) { + return Promise.resolve( + jsonResponse({ + errors: [ + { message: "unauthorized", extensions: { code: "UNAUTHORIZED" } }, + ], + }), + ); + } + return Promise.resolve(jsonResponse(resultBody(COMPACT_CANDIDATE))); + }); + const forceRefresh = mock(() => Promise.resolve("new-token")); + const service = new ResolveTargetServiceImpl( + ENDPOINT, + createMockTokenProvider({ forceRefresh }), + asFetchFn(fetchFn), + ); + + await expect( + service.resolveTarget({ + name: "express", + limit: 8, + includeDetailedFields: false, + }), + ).resolves.toBeDefined(); + expect(forceRefresh).toHaveBeenCalledTimes(1); + }); + + it("preserves shared HTTP access classification", async () => { + const service = new ResolveTargetServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn( + mock(() => Promise.resolve(jsonResponse({ detail: "no access" }, 403))), + ), + ); + + await expect( + service.resolveTarget({ + name: "express", + limit: 8, + includeDetailedFields: false, + }), + ).rejects.toBeInstanceOf(PackageIntelligenceAccessError); + }); + + it("maps feature-gate and validation GraphQL errors", async () => { + for (const [code, errorClass] of [ + ["FEATURE_FLAG_REQUIRED", PackageIntelligenceFeatureFlagRequiredError], + ["VALIDATION_ERROR", PackageIntelligenceValidationError], + ] as const) { + const service = new ResolveTargetServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn( + mock(() => + Promise.resolve( + jsonResponse({ + errors: [{ message: code, extensions: { code } }], + }), + ), + ), + ), + ); + + await expect( + service.resolveTarget({ + name: "express", + limit: 8, + includeDetailedFields: false, + }), + ).rejects.toBeInstanceOf(errorClass); + } + }); + + it("propagates authentication when refresh cannot supply a token", async () => { + const service = new ResolveTargetServiceImpl( + ENDPOINT, + createMockTokenProvider({ + forceRefresh: mock(() => Promise.resolve(undefined)), + }), + asFetchFn( + mock(() => + Promise.resolve( + jsonResponse({ + errors: [ + { + message: "unauthorized", + extensions: { code: "UNAUTHORIZED" }, + }, + ], + }), + ), + ), + ), + ); + + await expect( + service.resolveTarget({ + name: "express", + limit: 8, + includeDetailedFields: false, + }), + ).rejects.toBeInstanceOf(AuthenticationError); + }); +}); diff --git a/packages/core-internal/src/services/resolve-target-service.ts b/packages/core-internal/src/services/resolve-target-service.ts new file mode 100644 index 00000000..a816f8ac --- /dev/null +++ b/packages/core-internal/src/services/resolve-target-service.ts @@ -0,0 +1,317 @@ +import { z } from "zod"; +import { + type PkgseerGraphqlResponse, + PkgseerTransportError, + postPkgseerGraphql, +} from "../shared/pkgseer-graphql.js"; +import type { PkgseerRegistry } from "../shared/pkgseer-registry.js"; +import type { ClientHeaderBuilder } from "../shared/request-headers.js"; +import { withTelemetrySpan } from "../shared/telemetry.js"; +import { executeWithTokenRefresh } from "./execute-with-token-refresh.js"; +import { AuthenticationError } from "./githits-service.js"; +import { + createPackageIntelligenceGraphQLError, + createPackageIntelligenceHttpError, + createPackageIntelligenceTransportError, + MalformedPackageIntelligenceResponseError, +} from "./package-intelligence-service.js"; +import type { TokenProvider } from "./token-provider.js"; + +export type ResolveTargetKind = "PACKAGE" | "REPOSITORY"; + +export interface ResolveTargetParams { + name: string; + query?: string; + registries?: PkgseerRegistry[]; + preferredKinds?: ResolveTargetKind[]; + intentHints?: string[]; + limit: number; + includeDetailedFields: boolean; +} + +export interface ResolveTargetCandidate { + kind: string; + canonicalKey: string; + displayName: string; + description?: string; + registry?: string; + packageName?: string; + latestVersion?: string; + repositoryUrl?: string; + repositoryOwner?: string; + repositoryName?: string; + stars?: number; + downloadsLastMonth?: number; + downloadsTotal?: number; + documentationUrl?: string; + matchedAliases?: string[]; + docsAvailable: boolean; + codeAvailable: boolean; + protected: boolean; + matchTier?: number; + score?: number; + confidence: string; + reason?: string; +} + +export interface ResolveTargetResult { + best?: ResolveTargetCandidate; + protectedMatches: ResolveTargetCandidate[]; + candidates: ResolveTargetCandidate[]; + ambiguous: boolean; + ambiguousReason: string; +} + +export interface ResolveTargetService { + resolveTarget(params: ResolveTargetParams): Promise; +} + +const compactCandidateSchema = z.object({ + kind: z.string(), + canonicalKey: z.string(), + displayName: z.string(), + description: z.string().nullable().optional(), + registry: z.string().nullable().optional(), + stars: z.number().int().nullable().optional(), + downloadsLastMonth: z.number().int().nullable().optional(), + docsAvailable: z.boolean(), + codeAvailable: z.boolean(), + protected: z.boolean(), + confidence: z.string(), +}); + +const detailedCandidateSchema = compactCandidateSchema.extend({ + packageName: z.string().nullable().optional(), + latestVersion: z.string().nullable().optional(), + repositoryUrl: z.string().nullable().optional(), + repositoryOwner: z.string().nullable().optional(), + repositoryName: z.string().nullable().optional(), + downloadsTotal: z.number().int().nullable().optional(), + documentationUrl: z.string().nullable().optional(), + matchedAliases: z.array(z.string()), + matchTier: z.number().int(), + score: z.number(), + reason: z.string().nullable().optional(), +}); + +const graphQLErrorSchema = z.object({ + message: z.string(), + extensions: z.record(z.string(), z.unknown()).optional(), +}); + +function responseSchema( + candidateSchema: Candidate, +) { + const resultSchema = z.object({ + best: candidateSchema.nullable(), + protectedMatches: z.array(candidateSchema), + candidates: z.array(candidateSchema), + ambiguous: z.boolean(), + ambiguousReason: z.string(), + }); + + return z.object({ + data: z + .object({ resolveTarget: resultSchema.nullable() }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), + }); +} + +export const RESOLVE_TARGET_QUERY = ` +query ResolveTarget( + $name: String! + $query: String + $registries: [Registry!] + $preferredKinds: [TargetResolutionKind!] + $intentHints: [String!] + $limit: Int! + $includeDetailedFields: Boolean! +) { + resolveTarget( + name: $name + query: $query + registries: $registries + preferredKinds: $preferredKinds + intentHints: $intentHints + limit: $limit + ) { + best { ...ResolveTargetCandidateFields } + protectedMatches { ...ResolveTargetCandidateFields } + candidates { ...ResolveTargetCandidateFields } + ambiguous + ambiguousReason + } +} + +fragment ResolveTargetCandidateFields on TargetResolutionCandidate { + kind + canonicalKey + displayName + description + registry + stars + downloadsLastMonth + docsAvailable + codeAvailable + protected + confidence + packageName @include(if: $includeDetailedFields) + latestVersion @include(if: $includeDetailedFields) + repositoryUrl @include(if: $includeDetailedFields) + repositoryOwner @include(if: $includeDetailedFields) + repositoryName @include(if: $includeDetailedFields) + downloadsTotal @include(if: $includeDetailedFields) + documentationUrl @include(if: $includeDetailedFields) + matchedAliases @include(if: $includeDetailedFields) + matchTier @include(if: $includeDetailedFields) + score @include(if: $includeDetailedFields) + reason @include(if: $includeDetailedFields) +}`; + +export class ResolveTargetServiceImpl implements ResolveTargetService { + constructor( + private readonly endpointUrl: string, + private readonly tokenProvider: TokenProvider, + private readonly fetchFn: typeof fetch = globalThis.fetch, + private readonly runtime: { + clientHeaders?: ClientHeaderBuilder; + userAgent?: string; + clientVersion?: string; + } = {}, + ) {} + + async resolveTarget( + params: ResolveTargetParams, + ): Promise { + return withTelemetrySpan("resolve-target.request", () => + executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: (token) => this.executeResolveTarget(token, params), + }), + ); + } + + private async executeResolveTarget( + token: string, + params: ResolveTargetParams, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.endpointUrl, + token, + query: RESOLVE_TARGET_QUERY, + variables: buildVariables(params), + fetchFn: this.fetchFn, + clientHeaders: this.runtime.clientHeaders, + userAgent: this.runtime.userAgent, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) { + throw createPackageIntelligenceTransportError(cause); + } + throw cause; + } + + if (response.status < 200 || response.status >= 300) { + throw createPackageIntelligenceHttpError(response); + } + + const candidateSchema = params.includeDetailedFields + ? detailedCandidateSchema + : compactCandidateSchema; + const parsed = responseSchema(candidateSchema).safeParse( + response.parsedBody, + ); + if (!parsed.success) { + throw new MalformedPackageIntelligenceResponseError( + "Malformed response from the target-resolution service.", + ); + } + + if (parsed.data.errors && parsed.data.errors.length > 0) { + throw createPackageIntelligenceGraphQLError( + parsed.data.errors, + this.runtime.clientVersion, + ); + } + + const result = parsed.data.data?.resolveTarget; + if (!result) { + throw new MalformedPackageIntelligenceResponseError( + "Empty response from the target-resolution service.", + ); + } + + return { + best: result.best ? normaliseCandidate(result.best) : undefined, + protectedMatches: result.protectedMatches.map(normaliseCandidate), + candidates: result.candidates.map(normaliseCandidate), + ambiguous: result.ambiguous, + ambiguousReason: result.ambiguousReason, + }; + } +} + +function buildVariables(params: ResolveTargetParams): Record { + const variables: Record = { + name: params.name, + limit: params.limit, + includeDetailedFields: params.includeDetailedFields, + }; + if (params.query !== undefined) variables.query = params.query; + if (params.registries !== undefined) variables.registries = params.registries; + if (params.preferredKinds !== undefined) { + variables.preferredKinds = params.preferredKinds; + } + if (params.intentHints !== undefined) + variables.intentHints = params.intentHints; + return variables; +} + +function normaliseCandidate( + candidate: + | z.infer + | z.infer, +): ResolveTargetCandidate { + const result: ResolveTargetCandidate = { + kind: candidate.kind, + canonicalKey: candidate.canonicalKey, + displayName: candidate.displayName, + docsAvailable: candidate.docsAvailable, + codeAvailable: candidate.codeAvailable, + protected: candidate.protected, + confidence: candidate.confidence, + }; + + assignDefined(result, "description", candidate.description); + assignDefined(result, "registry", candidate.registry); + assignDefined(result, "stars", candidate.stars); + assignDefined(result, "downloadsLastMonth", candidate.downloadsLastMonth); + if ("matchedAliases" in candidate) { + assignDefined(result, "packageName", candidate.packageName); + assignDefined(result, "latestVersion", candidate.latestVersion); + assignDefined(result, "repositoryUrl", candidate.repositoryUrl); + assignDefined(result, "repositoryOwner", candidate.repositoryOwner); + assignDefined(result, "repositoryName", candidate.repositoryName); + assignDefined(result, "downloadsTotal", candidate.downloadsTotal); + assignDefined(result, "documentationUrl", candidate.documentationUrl); + assignDefined(result, "matchedAliases", candidate.matchedAliases); + assignDefined(result, "matchTier", candidate.matchTier); + assignDefined(result, "score", candidate.score); + assignDefined(result, "reason", candidate.reason); + } + return result; +} + +function assignDefined( + target: ResolveTargetCandidate, + key: Key, + value: ResolveTargetCandidate[Key] | null | undefined, +): void { + if (value !== null && value !== undefined) target[key] = value; +} From 661636667d066b8a25be16c53d2219a24993ac23 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 3 Aug 2026 14:53:27 +0300 Subject: [PATCH 2/8] feat: add resolve CLI command Add shared request normalization and compact response formatting, wire the internal service through both auth paths, and expose the branch-local dogfood command. --- .../src/shared/pkgseer-registry.test.ts | 2 + .../src/shared/pkgseer-registry.ts | 2 +- packages/mcp/src/internal.ts | 2 + .../src/shared/resolve-target-request.test.ts | 98 ++++++ .../mcp/src/shared/resolve-target-request.ts | 101 ++++++ .../shared/resolve-target-response.test.ts | 218 +++++++++++++ .../mcp/src/shared/resolve-target-response.ts | 223 +++++++++++++ src/cli.ts | 3 + src/commands/index.ts | 6 + src/commands/resolve.test.ts | 308 ++++++++++++++++++ src/commands/resolve.ts | 114 +++++++ src/container.test.ts | 25 ++ src/container.ts | 18 + src/services/test-helpers.ts | 31 ++ 14 files changed, 1150 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/src/shared/resolve-target-request.test.ts create mode 100644 packages/mcp/src/shared/resolve-target-request.ts create mode 100644 packages/mcp/src/shared/resolve-target-response.test.ts create mode 100644 packages/mcp/src/shared/resolve-target-response.ts create mode 100644 src/commands/resolve.test.ts create mode 100644 src/commands/resolve.ts diff --git a/packages/core-internal/src/shared/pkgseer-registry.test.ts b/packages/core-internal/src/shared/pkgseer-registry.test.ts index 8ad1a91e..633c4c86 100644 --- a/packages/core-internal/src/shared/pkgseer-registry.test.ts +++ b/packages/core-internal/src/shared/pkgseer-registry.test.ts @@ -50,5 +50,7 @@ describe("isKnownPkgseerRegistryArg", () => { expect(isKnownPkgseerRegistryArg("NPM")).toBe(false); expect(isKnownPkgseerRegistryArg("foobar")).toBe(false); expect(isKnownPkgseerRegistryArg("")).toBe(false); + expect(isKnownPkgseerRegistryArg("constructor")).toBe(false); + expect(isKnownPkgseerRegistryArg("__proto__")).toBe(false); }); }); diff --git a/packages/core-internal/src/shared/pkgseer-registry.ts b/packages/core-internal/src/shared/pkgseer-registry.ts index 57d08863..79f6a1ee 100644 --- a/packages/core-internal/src/shared/pkgseer-registry.ts +++ b/packages/core-internal/src/shared/pkgseer-registry.ts @@ -88,7 +88,7 @@ export function toPkgseerRegistryLowercase( export function isKnownPkgseerRegistryArg( value: string, ): value is PkgseerRegistryArg { - return value in registryMap; + return Object.hasOwn(registryMap, value); } export function knownPkgseerRegistryArgs(): ReadonlyArray { diff --git a/packages/mcp/src/internal.ts b/packages/mcp/src/internal.ts index cda9dbb3..f8b9fb92 100644 --- a/packages/mcp/src/internal.ts +++ b/packages/mcp/src/internal.ts @@ -56,6 +56,8 @@ export * from "./shared/read-package-doc-response.js"; export * from "./shared/read-package-doc-text.js"; export * from "./shared/repository-target.js"; export * from "./shared/require-auth.js"; +export * from "./shared/resolve-target-request.js"; +export * from "./shared/resolve-target-response.js"; export * from "./shared/shell-quote.js"; export * from "./shared/target-resolution.js"; export * from "./shared/unified-search-request.js"; diff --git a/packages/mcp/src/shared/resolve-target-request.test.ts b/packages/mcp/src/shared/resolve-target-request.test.ts new file mode 100644 index 00000000..a56ef577 --- /dev/null +++ b/packages/mcp/src/shared/resolve-target-request.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "bun:test"; +import { + buildResolveTargetParams, + RESOLVE_TARGET_DEFAULT_LIMIT, +} from "./resolve-target-request.js"; + +describe("buildResolveTargetParams", () => { + it("trims the name and applies the shared default", () => { + expect( + buildResolveTargetParams({ + name: " express ", + includeDetailedFields: false, + }), + ).toEqual({ + name: "express", + limit: RESOLVE_TARGET_DEFAULT_LIMIT, + includeDetailedFields: false, + }); + }); + + it("normalizes all ranking hints to GraphQL params", () => { + expect( + buildResolveTargetParams({ + name: "express", + query: " web framework ", + registry: " npm, PYPI, npm, ,", + preferKind: " Repository ", + intentHints: [" Server ", "server", "", "CLI"], + limit: 3, + includeDetailedFields: true, + }), + ).toEqual({ + name: "express", + query: "web framework", + registries: ["NPM", "PYPI"], + preferredKinds: ["REPOSITORY"], + intentHints: ["Server", "CLI"], + limit: 3, + includeDetailedFields: true, + }); + }); + + it("drops empty optional values", () => { + expect( + buildResolveTargetParams({ + name: "express", + query: " ", + registry: " , ", + preferKind: " ", + intentHints: ["", " "], + includeDetailedFields: false, + }), + ).toEqual({ + name: "express", + limit: 8, + includeDetailedFields: false, + }); + }); + + it("rejects empty names and unsupported enums", () => { + expect(() => + buildResolveTargetParams({ name: " ", includeDetailedFields: false }), + ).toThrow("Target name is required"); + expect(() => + buildResolveTargetParams({ + name: "x", + registry: "cargo", + includeDetailedFields: false, + }), + ).toThrow("Unsupported registry 'cargo'"); + expect(() => + buildResolveTargetParams({ + name: "x", + registry: "constructor", + includeDetailedFields: false, + }), + ).toThrow("Unsupported registry 'constructor'"); + expect(() => + buildResolveTargetParams({ + name: "x", + preferKind: "site", + includeDetailedFields: false, + }), + ).toThrow("prefer-kind expects package or repository"); + }); + + it("rejects non-integer and out-of-range limits", () => { + for (const limit of [0, 21, 1.5, Number.NaN]) { + expect(() => + buildResolveTargetParams({ + name: "x", + limit, + includeDetailedFields: false, + }), + ).toThrow("limit expects an integer between 1 and 20"); + } + }); +}); diff --git a/packages/mcp/src/shared/resolve-target-request.ts b/packages/mcp/src/shared/resolve-target-request.ts new file mode 100644 index 00000000..a2fa4fbe --- /dev/null +++ b/packages/mcp/src/shared/resolve-target-request.ts @@ -0,0 +1,101 @@ +import { + isKnownPkgseerRegistryArg, + PKGSEER_REGISTRY_LIST, + type PkgseerRegistry, + type PkgseerRegistryArg, + type ResolveTargetKind, + type ResolveTargetParams, + toPkgseerRegistry, +} from "@githits/core-internal"; +import { InvalidPackageSpecError } from "./package-spec.js"; + +export const RESOLVE_TARGET_DEFAULT_LIMIT = 8; +export const RESOLVE_TARGET_MAX_LIMIT = 20; + +export interface ResolveTargetRequestInput { + name: string; + query?: string; + registry?: string; + preferKind?: string; + intentHints?: string[]; + limit?: number; + includeDetailedFields: boolean; +} + +export function buildResolveTargetParams( + input: ResolveTargetRequestInput, +): ResolveTargetParams { + const name = input.name?.trim() ?? ""; + if (!name) throw new InvalidPackageSpecError("Target name is required."); + + const limit = input.limit ?? RESOLVE_TARGET_DEFAULT_LIMIT; + if ( + !Number.isInteger(limit) || + limit < 1 || + limit > RESOLVE_TARGET_MAX_LIMIT + ) { + throw new InvalidPackageSpecError( + `limit expects an integer between 1 and ${RESOLVE_TARGET_MAX_LIMIT}. Got ${String(limit)}.`, + ); + } + + const params: ResolveTargetParams = { + name, + limit, + includeDetailedFields: input.includeDetailedFields, + }; + const query = input.query?.trim(); + if (query) params.query = query; + + const registries = parseRegistries(input.registry); + if (registries.length > 0) params.registries = registries; + + const preferredKind = parsePreferredKind(input.preferKind); + if (preferredKind) params.preferredKinds = [preferredKind]; + + const intentHints = normaliseStrings(input.intentHints); + if (intentHints.length > 0) params.intentHints = intentHints; + return params; +} + +function parseRegistries(value: string | undefined): PkgseerRegistry[] { + if (value === undefined) return []; + const registries: PkgseerRegistry[] = []; + for (const raw of value.split(",")) { + const registry = raw.trim().toLowerCase(); + if (!registry) continue; + if (!isKnownPkgseerRegistryArg(registry)) { + throw new InvalidPackageSpecError( + `Unsupported registry '${raw.trim()}'. Supported: ${PKGSEER_REGISTRY_LIST}.`, + ); + } + const mapped = toPkgseerRegistry(registry as PkgseerRegistryArg); + if (!registries.includes(mapped)) registries.push(mapped); + } + return registries; +} + +function parsePreferredKind( + value: string | undefined, +): ResolveTargetKind | undefined { + const kind = value?.trim().toLowerCase(); + if (!kind) return undefined; + if (kind === "package") return "PACKAGE"; + if (kind === "repository") return "REPOSITORY"; + throw new InvalidPackageSpecError( + `prefer-kind expects package or repository. Got '${value}'.`, + ); +} + +function normaliseStrings(values: string[] | undefined): string[] { + const result: string[] = []; + const seen = new Set(); + for (const raw of values ?? []) { + const value = raw.trim(); + const key = value.toLowerCase(); + if (!value || seen.has(key)) continue; + seen.add(key); + result.push(value); + } + return result; +} diff --git a/packages/mcp/src/shared/resolve-target-response.test.ts b/packages/mcp/src/shared/resolve-target-response.test.ts new file mode 100644 index 00000000..cdd94b53 --- /dev/null +++ b/packages/mcp/src/shared/resolve-target-response.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "bun:test"; +import type { + ResolveTargetCandidate, + ResolveTargetResult, +} from "@githits/core-internal"; +import { + buildResolveTargetSuccessPayload, + formatResolveTargetTerminal, +} from "./resolve-target-response.js"; + +function candidate( + overrides: Partial = {}, +): ResolveTargetCandidate { + return { + kind: "PACKAGE", + canonicalKey: "npm:express", + displayName: "express", + description: "Fast web framework", + registry: "NPM", + latestVersion: "5.1.0", + stars: 66_000, + downloadsLastMonth: 89_000_000, + matchedAliases: ["express"], + docsAvailable: true, + codeAvailable: true, + protected: true, + matchTier: 0, + score: 100, + confidence: "EXACT", + reason: "Exact package identity match", + ...overrides, + }; +} + +function result( + overrides: Partial = {}, +): ResolveTargetResult { + const best = candidate(); + return { + best, + protectedMatches: [best], + candidates: [best], + ambiguous: false, + ambiguousReason: "NOT_AMBIGUOUS", + ...overrides, + }; +} + +describe("buildResolveTargetSuccessPayload", () => { + it("builds a lowercase, null-free diagnostic envelope", () => { + expect(buildResolveTargetSuccessPayload(result())).toEqual({ + best: "npm:express", + ambiguous: false, + candidates: [ + { + target: "npm:express", + name: "express", + kind: "package", + confidence: "exact", + description: "Fast web framework", + registry: "npm", + latestVersion: "5.1.0", + stars: 66_000, + downloadsLastMonth: 89_000_000, + matchedAliases: ["express"], + docsAvailable: true, + codeAvailable: true, + matchTier: 0, + score: 100, + reason: "Exact package identity match", + }, + ], + protectedMatches: ["npm:express"], + }); + }); + + it("appends unbounded protected extras once so every reference resolves", () => { + const extra = candidate({ + canonicalKey: "pypi:express", + registry: "PYPI", + }); + const payload = buildResolveTargetSuccessPayload( + result({ protectedMatches: [candidate(), extra, extra] }), + ); + + expect(payload.candidates.map((entry) => entry.target)).toEqual([ + "npm:express", + "pypi:express", + ]); + expect(payload.protectedMatches).toEqual(["npm:express", "pypi:express"]); + }); + + it("uses safe lowercase strings for unknown enum values", () => { + const unknown = candidate({ kind: "WORKSPACE", confidence: "VERY_HIGH" }); + const payload = buildResolveTargetSuccessPayload( + result({ best: unknown, candidates: [unknown], protectedMatches: [] }), + ); + expect(payload.candidates[0]?.kind).toBe("workspace"); + expect(payload.candidates[0]?.confidence).toBe("very_high"); + }); + + it("emits the compact empty envelope", () => { + expect( + buildResolveTargetSuccessPayload( + result({ + best: undefined, + candidates: [], + protectedMatches: [], + }), + ), + ).toEqual({ + ambiguous: false, + candidates: [], + protectedMatches: [], + }); + }); +}); + +describe("formatResolveTargetTerminal", () => { + it("renders a compact best result and copyable supplied-query follow-up", () => { + const output = formatResolveTargetTerminal(result(), { + name: "express", + query: "router's middleware", + useColors: false, + }); + + expect(output).toContain( + "Best: npm:express [exact] · package · 66k stars · 89M downloads/mo · docs · code", + ); + expect(output).toContain(" Fast web framework"); + expect(output).toContain( + `Next: githits search 'router'"'"'s middleware' --in 'npm:express'`, + ); + }); + + it("partitions protected matches from ranked alternatives", () => { + const protectedExtra = candidate({ + canonicalKey: "pypi:express", + registry: "PYPI", + }); + const alternative = candidate({ + kind: "REPOSITORY", + canonicalKey: "github:expressjs/express", + displayName: "expressjs/express", + protected: false, + confidence: "HIGH", + }); + const output = formatResolveTargetTerminal( + result({ + protectedMatches: [candidate(), protectedExtra], + candidates: [candidate(), protectedExtra, alternative], + }), + { name: "express", useColors: false }, + ); + + expect(output).toContain( + "Protected exact-name matches:\n pypi:express [exact] · package", + ); + expect(output).toContain( + "Also consider:\n github:expressjs/express [high] · repository", + ); + expect(output).toContain("githits search ''"); + }); + + it("renders specific ambiguity guidance and Top wording", () => { + const messages = { + DUPLICATE_EXACT_NAME: + "multiple exact package names match; narrow with --registry", + CLOSE_CANDIDATES: "top candidates are equally plausible", + LOW_CONFIDENCE: "only low-confidence matches were found", + NEW_REASON: "resolver reported new_reason", + }; + for (const [ambiguousReason, message] of Object.entries(messages)) { + const output = formatResolveTargetTerminal( + result({ ambiguous: true, ambiguousReason }), + { name: "express", useColors: false }, + ); + expect(output).toContain(`Ambiguous: ${message}`); + expect(output).toContain("Top: npm:express"); + } + }); + + it("uses Top for a non-ambiguous medium result", () => { + const medium = candidate({ confidence: "MEDIUM" }); + expect( + formatResolveTargetTerminal( + result({ best: medium, candidates: [medium], protectedMatches: [] }), + { name: "express", useColors: false }, + ), + ).toContain("Top: npm:express"); + }); + + it("normalizes and caps the best description at 120 characters", () => { + const long = candidate({ description: `first\n${"x".repeat(150)}` }); + const output = formatResolveTargetTerminal( + result({ best: long, candidates: [long] }), + { name: "express", useColors: false }, + ); + const description = output.split("\n")[1]?.trim() ?? ""; + expect(description.length).toBe(120); + expect(description).toEndWith("..."); + }); + + it("renders no-result text and optional ANSI colors", () => { + expect( + formatResolveTargetTerminal( + result({ best: undefined, candidates: [], protectedMatches: [] }), + { name: "missing", useColors: false }, + ), + ).toBe("No targets found for 'missing'.\n"); + expect( + formatResolveTargetTerminal(result(), { + name: "express", + useColors: true, + }), + ).toContain("\x1b["); + }); +}); diff --git a/packages/mcp/src/shared/resolve-target-response.ts b/packages/mcp/src/shared/resolve-target-response.ts new file mode 100644 index 00000000..c14c47e8 --- /dev/null +++ b/packages/mcp/src/shared/resolve-target-response.ts @@ -0,0 +1,223 @@ +import type { + ResolveTargetCandidate, + ResolveTargetResult, +} from "@githits/core-internal"; +import { colorize, dim, highlight } from "./colors.js"; +import { formatCompactNumber } from "./format-number.js"; +import { shellQuote } from "./shell-quote.js"; + +export interface ResolveTargetCandidatePayload { + target: string; + name: string; + kind: string; + confidence: string; + description?: string; + registry?: string; + packageName?: string; + latestVersion?: string; + repositoryUrl?: string; + repositoryOwner?: string; + repositoryName?: string; + stars?: number; + downloadsLastMonth?: number; + downloadsTotal?: number; + documentationUrl?: string; + matchedAliases?: string[]; + docsAvailable: boolean; + codeAvailable: boolean; + matchTier?: number; + score?: number; + reason?: string; +} + +export interface ResolveTargetPayload { + best?: string; + ambiguous: boolean; + ambiguousReason?: string; + candidates: ResolveTargetCandidatePayload[]; + protectedMatches: string[]; +} + +/** Build the stable, non-duplicating JSON projection used by CLI and MCP. */ +export function buildResolveTargetSuccessPayload( + result: ResolveTargetResult, +): ResolveTargetPayload { + const candidates = dedupeCandidates([ + ...result.candidates, + ...result.protectedMatches, + ...(result.best ? [result.best] : []), + ]).map(projectCandidate); + const payload: ResolveTargetPayload = { + ambiguous: result.ambiguous, + candidates, + protectedMatches: dedupeCandidates(result.protectedMatches).map( + (candidate) => candidate.canonicalKey, + ), + }; + if (result.best) payload.best = result.best.canonicalKey; + if (result.ambiguous) { + payload.ambiguousReason = result.ambiguousReason.toLowerCase(); + } + return payload; +} + +function projectCandidate( + candidate: ResolveTargetCandidate, +): ResolveTargetCandidatePayload { + const payload: ResolveTargetCandidatePayload = { + target: candidate.canonicalKey, + name: candidate.displayName, + kind: candidate.kind.toLowerCase(), + confidence: candidate.confidence.toLowerCase(), + docsAvailable: candidate.docsAvailable, + codeAvailable: candidate.codeAvailable, + }; + assign(payload, "description", candidate.description); + assign(payload, "registry", candidate.registry?.toLowerCase()); + assign(payload, "packageName", candidate.packageName); + assign(payload, "latestVersion", candidate.latestVersion); + assign(payload, "repositoryUrl", candidate.repositoryUrl); + assign(payload, "repositoryOwner", candidate.repositoryOwner); + assign(payload, "repositoryName", candidate.repositoryName); + assign(payload, "stars", candidate.stars); + assign(payload, "downloadsLastMonth", candidate.downloadsLastMonth); + assign(payload, "downloadsTotal", candidate.downloadsTotal); + assign(payload, "documentationUrl", candidate.documentationUrl); + assign(payload, "matchedAliases", candidate.matchedAliases); + assign(payload, "matchTier", candidate.matchTier); + assign(payload, "score", candidate.score); + assign(payload, "reason", candidate.reason); + return payload; +} + +export interface FormatResolveTargetTerminalOptions { + name: string; + query?: string; + useColors?: boolean; +} + +/** Render a compact result with explicit confidence and a copyable follow-up. */ +export function formatResolveTargetTerminal( + result: ResolveTargetResult, + options: FormatResolveTargetTerminalOptions, +): string { + if (!result.best) return `No targets found for '${options.name}'.\n`; + const useColors = options.useColors ?? false; + const lines: string[] = []; + if (result.ambiguous) lines.push(ambiguityMessage(result.ambiguousReason)); + + const bestLabel = + !result.ambiguous && ["EXACT", "HIGH"].includes(result.best.confidence) + ? "Best" + : "Top"; + lines.push( + `${colorize(`${bestLabel}:`, "green", useColors)} ${formatCandidate(result.best, useColors, true)}`, + ); + const description = compactDescription(result.best.description); + if (description) lines.push(` ${dim(description, useColors)}`); + + const bestKey = candidateKey(result.best); + const protectedMatches = dedupeCandidates(result.protectedMatches).filter( + (candidate) => candidateKey(candidate) !== bestKey, + ); + if (protectedMatches.length > 0) { + lines.push("", "Protected exact-name matches:"); + lines.push( + ...protectedMatches.map( + (candidate) => ` ${formatCandidate(candidate, useColors, false)}`, + ), + ); + } + + const protectedKeys = new Set( + result.protectedMatches.map((candidate) => candidateKey(candidate)), + ); + const alternatives = dedupeCandidates(result.candidates).filter( + (candidate) => { + const key = candidateKey(candidate); + return key !== bestKey && !protectedKeys.has(key); + }, + ); + if (alternatives.length > 0) { + lines.push("", "Also consider:"); + lines.push( + ...alternatives.map( + (candidate) => ` ${formatCandidate(candidate, useColors, false)}`, + ), + ); + } + + const query = options.query?.trim() || ""; + lines.push( + "", + `Next: githits search ${shellQuote(query)} --in ${shellQuote(result.best.canonicalKey)}`, + ); + return `${lines.join("\n")}\n`; +} + +function formatCandidate( + candidate: ResolveTargetCandidate, + useColors: boolean, + detailed: boolean, +): string { + const fields = [ + `${highlight(candidate.canonicalKey, useColors)} [${candidate.confidence.toLowerCase()}]`, + candidate.kind.toLowerCase(), + ]; + if (detailed && candidate.stars !== undefined) { + fields.push(`${formatCompactNumber(candidate.stars)} stars`); + } + if (detailed && candidate.downloadsLastMonth !== undefined) { + fields.push( + `${formatCompactNumber(candidate.downloadsLastMonth)} downloads/mo`, + ); + } + if (detailed && candidate.docsAvailable) fields.push("docs"); + if (detailed && candidate.codeAvailable) fields.push("code"); + return fields.join(" · "); +} + +function ambiguityMessage(reason: string): string { + switch (reason) { + case "DUPLICATE_EXACT_NAME": + return "Ambiguous: multiple exact package names match; narrow with --registry."; + case "CLOSE_CANDIDATES": + return "Ambiguous: top candidates are equally plausible; review before use."; + case "LOW_CONFIDENCE": + return "Ambiguous: only low-confidence matches were found; review before use."; + default: + return `Ambiguous: resolver reported ${reason.toLowerCase()}; review before use.`; + } +} + +function compactDescription(value: string | undefined): string | undefined { + const normalized = value?.replace(/\s+/g, " ").trim(); + if (!normalized) return undefined; + return normalized.length > 120 + ? `${normalized.slice(0, 117).trimEnd()}...` + : normalized; +} + +function dedupeCandidates( + candidates: ResolveTargetCandidate[], +): ResolveTargetCandidate[] { + const seen = new Set(); + return candidates.filter((candidate) => { + const key = candidateKey(candidate); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function candidateKey(candidate: ResolveTargetCandidate): string { + return `${candidate.kind}:${candidate.canonicalKey}`; +} + +function assign( + target: ResolveTargetCandidatePayload, + key: Key, + value: ResolveTargetCandidatePayload[Key] | undefined, +): void { + if (value !== undefined) target[key] = value; +} diff --git a/src/cli.ts b/src/cli.ts index 59702c88..9e3b47ac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ import { registerLogoutCommand, registerMcpCommand, registerPkgCommandGroup, + registerResolveCommand, registerUnifiedSearchCommands, } from "./commands/index.js"; import { loginFlow, stderrLoginOutput } from "./commands/login.js"; @@ -112,6 +113,7 @@ ${colorizeBrand("Getting started:", "primary", useColors, { bold: true })} githits login Sign in to your GitHits account githits mcp Show MCP setup instructions githits example "query" Find real-world implementations + githits resolve express Resolve a package or repository name Learn more at https://githits.com Docs: https://docs.githits.com @@ -133,6 +135,7 @@ Support: support@githits.com`, registerLanguagesCommand(program); registerFeedbackCommand(program); registerDoctorCommand(program); + registerResolveCommand(program); const registrationArgv = stripRootRegistrationOptions(argv); const updateCheckTask = startUpdateCheckTaskForInvocation({ args: argv, diff --git a/src/commands/index.ts b/src/commands/index.ts index a56b7352..9645d21c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -53,6 +53,12 @@ export { } from "./logout.js"; export { registerMcpCommand } from "./mcp.js"; export { registerPkgCommandGroup } from "./pkg/index.js"; +export { + type ResolveCommandDependencies, + type ResolveCommandOptions, + registerResolveCommand, + resolveAction, +} from "./resolve.js"; export { registerSearchCommand, registerUnifiedSearchCommands, diff --git a/src/commands/resolve.test.ts b/src/commands/resolve.test.ts new file mode 100644 index 00000000..4cc4da98 --- /dev/null +++ b/src/commands/resolve.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { PackageIntelligenceFeatureFlagRequiredError } from "@githits/core-internal"; +import { Command } from "commander"; +import { + createMockResolveTargetService, + defaultResolveTargetResult, +} from "../services/test-helpers.js"; +import { + type ResolveCommandDependencies, + registerResolveCommand, + resolveAction, +} from "./resolve.js"; + +function deps( + overrides: Partial = {}, +): ResolveCommandDependencies { + return { + resolveTargetService: createMockResolveTargetService(), + hasValidToken: true, + mcpUrl: "https://mcp.githits.com", + ...overrides, + }; +} + +afterEach(() => { + process.exitCode = 0; + mock.restore(); +}); + +describe("resolveAction", () => { + it("normalizes options, requests compact data, and renders terminal output", async () => { + const resolveTarget = mock(() => + Promise.resolve(defaultResolveTargetResult), + ); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + () => true, + ); + + await resolveAction( + " express ", + { + query: " web framework ", + registry: "npm,pypi", + preferKind: "package", + intentHint: ["server"], + limit: "3", + }, + deps({ + resolveTargetService: createMockResolveTargetService({ resolveTarget }), + }), + ); + + expect(resolveTarget).toHaveBeenCalledWith({ + name: "express", + query: "web framework", + registries: ["NPM", "PYPI"], + preferredKinds: ["PACKAGE"], + intentHints: ["server"], + limit: 3, + includeDetailedFields: false, + }); + expect(String(writeSpy.mock.calls[0]?.[0])).toContain("Best: npm:express"); + }); + + it("requests detailed data and prints clean JSON", async () => { + const resolveTarget = mock(() => + Promise.resolve(defaultResolveTargetResult), + ); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await resolveAction( + "express", + { json: true }, + deps({ + resolveTargetService: createMockResolveTargetService({ resolveTarget }), + }), + ); + + expect(resolveTarget).toHaveBeenCalledWith({ + name: "express", + limit: 8, + includeDetailedFields: true, + }); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + best: "npm:express", + ambiguous: false, + }); + }); + + it("prints the empty JSON envelope and sets exit code 1", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const empty = { + ...defaultResolveTargetResult, + best: undefined, + candidates: [], + protectedMatches: [], + }; + + await resolveAction( + "missing", + { json: true }, + deps({ + resolveTargetService: createMockResolveTargetService({ + resolveTarget: mock(() => Promise.resolve(empty)), + }), + }), + ); + + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual({ + ambiguous: false, + candidates: [], + protectedMatches: [], + }); + expect(process.exitCode).toBe(1); + }); + + it("prints text for no result and sets exit code 1", async () => { + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + () => true, + ); + const empty = { + ...defaultResolveTargetResult, + best: undefined, + candidates: [], + protectedMatches: [], + }; + + await resolveAction( + "missing", + {}, + deps({ + resolveTargetService: createMockResolveTargetService({ + resolveTarget: mock(() => Promise.resolve(empty)), + }), + }), + ); + + expect(String(writeSpy.mock.calls[0]?.[0])).toBe( + "No targets found for 'missing'.\n", + ); + expect(process.exitCode).toBe(1); + }); + + it("validates before calling the service and emits JSON errors on stderr", async () => { + const resolveTarget = mock(() => + Promise.resolve(defaultResolveTargetResult), + ); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + resolveAction( + " ", + { json: true }, + deps({ + resolveTargetService: createMockResolveTargetService({ + resolveTarget, + }), + }), + ), + ).rejects.toThrow("process.exit"); + + expect(resolveTarget).not.toHaveBeenCalled(); + expect(JSON.parse(String(errorSpy.mock.calls[0]?.[0])).code).toBe( + "INVALID_ARGUMENT", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("rejects partial numeric limits before calling the service", async () => { + const resolveTarget = mock(() => + Promise.resolve(defaultResolveTargetResult), + ); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + resolveAction( + "express", + { json: true, limit: "3x" }, + deps({ + resolveTargetService: createMockResolveTargetService({ + resolveTarget, + }), + }), + ), + ).rejects.toThrow("process.exit"); + + expect(resolveTarget).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(errorSpy.mock.calls[0]?.[0])).code).toBe( + "INVALID_ARGUMENT", + ); + }); + + it("requires authentication before calling the service", async () => { + const resolveTarget = mock(() => + Promise.resolve(defaultResolveTargetResult), + ); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + resolveAction( + "express", + { json: true }, + deps({ + hasValidToken: false, + resolveTargetService: createMockResolveTargetService({ + resolveTarget, + }), + }), + ), + ).rejects.toThrow("process.exit"); + + expect(resolveTarget).not.toHaveBeenCalled(); + expect(JSON.parse(String(errorSpy.mock.calls[0]?.[0])).code).toBe( + "AUTH_REQUIRED", + ); + }); + + it("maps feature-gate errors to ACCESS_DENIED", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + () => true, + ); + spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + resolveAction( + "express", + { json: true }, + deps({ + resolveTargetService: createMockResolveTargetService({ + resolveTarget: mock(() => + Promise.reject( + new PackageIntelligenceFeatureFlagRequiredError("not enabled"), + ), + ), + }), + }), + ), + ).rejects.toThrow("process.exit"); + + expect(JSON.parse(String(errorSpy.mock.calls[0]?.[0])).code).toBe( + "ACCESS_DENIED", + ); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it("renders mapped terminal errors on stderr only", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + () => true, + ); + spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + resolveAction( + "express", + {}, + deps({ + resolveTargetService: createMockResolveTargetService({ + resolveTarget: mock(() => + Promise.reject( + new PackageIntelligenceFeatureFlagRequiredError("not enabled"), + ), + ), + }), + }), + ), + ).rejects.toThrow("process.exit"); + + expect(errorSpy).toHaveBeenCalledWith("not enabled"); + expect(writeSpy).not.toHaveBeenCalled(); + }); +}); + +describe("registerResolveCommand", () => { + it("documents every option and the query privacy warning", () => { + const program = new Command(); + registerResolveCommand(program); + const help = program.commands[0]?.helpInformation() ?? ""; + + for (const value of [ + "--query", + "--registry", + "--prefer-kind", + "--intent-hint", + "--limit", + "--json", + "include credentials, personal data, private code", + ]) { + expect(help).toContain(value); + } + }); +}); diff --git a/src/commands/resolve.ts b/src/commands/resolve.ts new file mode 100644 index 00000000..e640d794 --- /dev/null +++ b/src/commands/resolve.ts @@ -0,0 +1,114 @@ +import type { ResolveTargetService } from "@githits/core-internal"; +import { + buildResolveTargetParams, + buildResolveTargetSuccessPayload, + formatResolveTargetTerminal, + mapPackageIntelligenceError, + requireAuth, + shouldUseColors, +} from "@githits/mcp/internal"; +import type { Command } from "commander"; +import { createContainer } from "../container.js"; +import { parseIntCliOption } from "../shared/cli-options.js"; +import { + buildCliMappedErrorPayload, + formatMappedErrorForTerminal, +} from "./format-mapped-error.js"; + +export interface ResolveCommandOptions { + query?: string; + registry?: string; + preferKind?: string; + intentHint?: string[]; + limit?: string; + json?: boolean; +} + +export interface ResolveCommandDependencies { + resolveTargetService: ResolveTargetService; + hasValidToken: boolean; + mcpUrl: string; +} + +export async function resolveAction( + name: string, + options: ResolveCommandOptions, + deps: ResolveCommandDependencies, +): Promise { + try { + requireAuth(deps); + const params = buildResolveTargetParams({ + name, + query: options.query, + registry: options.registry, + preferKind: options.preferKind, + intentHints: options.intentHint, + limit: parseIntCliOption(options.limit, "--limit", 1, 20), + includeDetailedFields: options.json === true, + }); + const result = await deps.resolveTargetService.resolveTarget(params); + + if (options.json) { + console.log(JSON.stringify(buildResolveTargetSuccessPayload(result))); + } else { + process.stdout.write( + formatResolveTargetTerminal(result, { + name: params.name, + query: params.query, + useColors: shouldUseColors(), + }), + ); + } + if (!result.best) process.exitCode = 1; + } catch (error) { + handleResolveError(error, options.json === true); + } +} + +function handleResolveError(error: unknown, json: boolean): never { + const mapped = mapPackageIntelligenceError(error); + console.error( + json + ? JSON.stringify(buildCliMappedErrorPayload(mapped)) + : formatMappedErrorForTerminal(mapped), + ); + process.exit(1); +} + +function collectIntentHint(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +const DESCRIPTION = `Resolve a human-provided name to ranked package or GitHub repository targets. + +The optional --query value is sent to the service as ranking context. Do not +include credentials, personal data, private code, or proprietary content.`; + +export function registerResolveCommand(program: Command): Command { + return program + .command("resolve") + .summary("Resolve a package or GitHub repository name") + .description(DESCRIPTION) + .argument("", "Package or GitHub repository name") + .option("-q, --query ", "Task context used as a soft ranking hint") + .option("--registry ", "Comma-separated package registries") + .option("--prefer-kind ", "Soft preference: package or repository") + .option( + "--intent-hint ", + "Soft intent hint (repeatable)", + collectIntentHint, + ) + .option( + "-n, --limit ", + "Ranked candidates (1-20, default 8); protected exact matches may be additional", + ) + .option("--json", "Emit structured diagnostic JSON") + .action(async (name: string, options: ResolveCommandOptions) => { + const deps = await createContainer(); + await resolveAction(name, options, { + resolveTargetService: deps.resolveTargetService, + hasValidToken: deps.hasValidToken, + mcpUrl: deps.mcpUrl, + }); + }); +} diff --git a/src/container.test.ts b/src/container.test.ts index c5737cb4..2207b595 100644 --- a/src/container.test.ts +++ b/src/container.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, mock } from "bun:test"; import { flushTelemetry, + ResolveTargetServiceImpl, resetTelemetryCollectorForTests, } from "@githits/core-internal"; import { @@ -148,6 +149,30 @@ describe("container auth dependencies", () => { }); describe("createContainer", () => { + it("constructs the resolve service for environment-token auth", async () => { + await withoutProxyEnv(async () => + withApiToken("ghi-test", async () => { + const deps = await createContainer({ resolveStoredToken: false }); + expect(deps.resolveTargetService).toBeInstanceOf( + ResolveTargetServiceImpl, + ); + }), + ); + }); + + it("constructs the resolve service for stored-token auth", async () => { + await withoutProxyEnv(async () => + withApiToken(undefined, async () => + withAuthStorageEnv("file", async () => { + const deps = await createContainer({ resolveStoredToken: false }); + expect(deps.resolveTargetService).toBeInstanceOf( + ResolveTargetServiceImpl, + ); + }), + ), + ); + }); + it("rejects insecure service URLs before constructing authenticated clients", async () => { await withEnvVars( { diff --git a/src/container.ts b/src/container.ts index c97b2b16..088e327a 100644 --- a/src/container.ts +++ b/src/container.ts @@ -15,6 +15,8 @@ import { type PackageIntelligenceService, PackageIntelligenceServiceImpl, RefreshingGitHitsService, + type ResolveTargetService, + ResolveTargetServiceImpl, startTelemetrySpan, withTelemetrySpan, } from "@githits/core-internal"; @@ -245,6 +247,8 @@ export interface Dependencies { * service. */ packageIntelligenceService: PackageIntelligenceService; + /** Resolves fuzzy package/repository names for the CLI dogfood surface. */ + resolveTargetService: ResolveTargetService; /** GitHits REST API service */ githitsService: GitHitsService; } @@ -305,6 +309,12 @@ export async function createContainer( fetchFn, serviceRuntime, ); + const resolveTargetService = new ResolveTargetServiceImpl( + codeNavigationUrl, + tokenProvider, + fetchFn, + serviceRuntime, + ); return { authStorage, @@ -319,6 +329,7 @@ export async function createContainer( codeNavigationUrl, codeNavigationService, packageIntelligenceService, + resolveTargetService, githitsService: new GitHitsServiceImpl( apiUrl, envToken, @@ -357,6 +368,12 @@ export async function createContainer( fetchFn, serviceRuntime, ); + const resolveTargetService = new ResolveTargetServiceImpl( + codeNavigationUrl, + tokenManager, + fetchFn, + serviceRuntime, + ); return { authStorage, @@ -371,6 +388,7 @@ export async function createContainer( codeNavigationUrl, codeNavigationService, packageIntelligenceService, + resolveTargetService, githitsService: new RefreshingGitHitsService( apiUrl, tokenManager, diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index f2529aa0..cc20df7b 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -11,6 +11,8 @@ import type { PackageIntelligenceService, PackageSummary, PackageUpgradeReviewResponse, + ResolveTargetResult, + ResolveTargetService, TokenProvider, UnifiedSearchOutcome, VulnerabilityReport, @@ -968,6 +970,35 @@ export function createMockPackageIntelligenceService( }; } +export const defaultResolveTargetResult: ResolveTargetResult = { + best: { + kind: "PACKAGE", + canonicalKey: "npm:express", + displayName: "express", + description: "Fast web framework", + registry: "NPM", + stars: 66_000, + downloadsLastMonth: 89_000_000, + docsAvailable: true, + codeAvailable: true, + protected: true, + confidence: "EXACT", + }, + protectedMatches: [], + candidates: [], + ambiguous: false, + ambiguousReason: "NOT_AMBIGUOUS", +}; + +export function createMockResolveTargetService( + impl: Partial = {}, +): ResolveTargetService { + return { + resolveTarget: mock(() => Promise.resolve(defaultResolveTargetResult)), + ...impl, + }; +} + /** * Creates a mock KeyringService with default implementations. */ From e4759c579c56cb4df0eabc445a3d2fc6877524c2 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 3 Aug 2026 15:26:06 +0300 Subject: [PATCH 3/8] test: validate resolve CLI dogfood flow Add product smoke and durable CLI documentation, tighten GraphQL field selection for compact output, and cover privacy guidance for both free-text ranking hints. --- docs/implementation/cli-commands.md | 34 ++++- docs/plans/resolve-target.md | 31 +++-- .../services/resolve-target-service.test.ts | 51 +++++--- .../src/services/resolve-target-service.ts | 120 +++++++++++------- .../shared/resolve-target-response.test.ts | 2 - .../mcp/src/shared/resolve-target-response.ts | 12 +- scripts/cli-smoke.ts | 88 +++++++++++++ src/commands/resolve.test.ts | 1 + src/commands/resolve.ts | 5 +- src/services/test-helpers.ts | 1 - 10 files changed, 261 insertions(+), 84 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index bfa95ac5..2fdfec73 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -2,7 +2,7 @@ ## Purpose -The CLI exposes setup/auth commands, `doctor`, `example`, `languages`, `feedback`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default. MCP-parity commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. +The CLI exposes setup/auth commands, `doctor`, `example`, `languages`, `feedback`, target `resolve`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default. MCP-parity commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. ## Commands @@ -17,6 +17,7 @@ The CLI exposes setup/auth commands, `doctor`, `example`, `languages`, `feedback | `languages [query]` | — | `--json` | List or filter supported languages | | `feedback [solution_id]` | `--accept` or `--reject` | `-m, --message `, `--tool `, `--json` | Submit solution-tied or generic session feedback | | `doctor` | — | `--json` | Print redacted diagnostics for GitHits runtime, environment, service URLs, config, and auth storage | +| `resolve ` | package or GitHub repository name | `--query`, `--registry`, `--prefer-kind`, repeatable `--intent-hint`, `--limit`, `--json` | Resolve a human-provided name to ranked concrete targets for follow-up commands | | `pkg info ` | package spec | `--verbose`, `--json` | Show a package overview (latest version, downloads, license, vulnerabilities) | | `pkg vulns ` | package spec (optional `@version`) | `--severity`, `--scope`, `--include-withdrawn`, `--verbose`, `--json` | List known vulnerabilities for a package (npm/pypi/hex/crates/nuget/maven/packagist/rubygems/go/swift) | | `pkg deps ` | package spec (optional `@version`) | `--lifecycle`, `--depth`, `--verbose`, `--json` | Analyse dependencies: direct runtime deps, structured groups, optional capped transitive graph (npm/pypi/hex/crates/vcpkg/zig/rubygems/go/swift) | @@ -184,6 +185,37 @@ githits doctor --json Prints redacted diagnostics for comparing GitHits behavior across terminals or agents. The report includes CLI/runtime identity, selected environment variables, service URL sources, config file status, active and legacy auth storage locations, token/client/metadata presence and timestamps, and recommendations. Secret-bearing values such as tokens, client secrets, API tokens, and proxy credentials are never printed; presence is reported as `set` / `present` only. JSON output uses `schemaVersion: 1` for support tooling. +### `githits resolve` + +```text +githits resolve express +githits resolve codex --prefer-kind repository +githits resolve guava --registry maven --limit 3 +githits resolve "pi agent" --query "coding agent CLI" --json +``` + +Resolves a human-provided package or GitHub repository name to ranked canonical +targets such as `npm:express` or `github:openai/codex`. The default output is a +compact best/top candidate block, ambiguity guidance when needed, protected +exact-name matches, alternatives, and a copyable `githits search --in` +follow-up. No candidates is a valid JSON/text result but exits 1 because the +command did not resolve a target. + +`--registry` accepts a comma-separated package-registry list; repository +candidates remain eligible. `--prefer-kind package|repository` is a soft +preference, not a filter. `--intent-hint` is repeatable. `--limit` controls the +ranked list from 1-20 (default 8); protected exact-name matches can be additional. +`--query` and `--intent-hint` are sent to the service as ranking context and +must not contain credentials, personal data, private code, or proprietary +content. + +`--json` emits the stable compact diagnostic envelope +`{best?, ambiguous, ambiguousReason?, candidates, protectedMatches}`. Candidate +objects occur once; `best` and `protectedMatches` use canonical-key references. +Detailed ranking fields are fetched only for JSON. Null fields are omitted and +enum values are lowercase. Errors use the standard JSON envelope on stderr with +clean stdout. + ### Proxy Support CLI-originated HTTP traffic uses `src/services/proxy-fetch.ts`. This includes OAuth discovery, client registration, token exchange/refresh, REST API calls, code/package service calls, local MCP tool calls started through `githits mcp start`, and npm update checks. The fetch factory supports `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` plus lowercase aliases; lowercase values win when both cases are set, matching undici's env proxy precedence. diff --git a/docs/plans/resolve-target.md b/docs/plans/resolve-target.md index f8776a8c..263ef8df 100644 --- a/docs/plans/resolve-target.md +++ b/docs/plans/resolve-target.md @@ -121,8 +121,9 @@ Options: --json emit structured diagnostic JSON ``` -Command help must state that `--query` is sent to the service and must not -contain credentials, personal data, private code, or proprietary content. +Command help must state that `--query` and `--intent-hint` are sent to the +service and must not contain credentials, personal data, private code, or +proprietary content. Normalization and validation, in `buildResolveTargetParams`: @@ -147,24 +148,32 @@ validation must preserve the standard terminal/JSON error envelopes. ## Wire contract -`RESOLVE_TARGET_QUERY` uses one candidate fragment across `best`, -`protectedMatches`, and `candidates`. +`RESOLVE_TARGET_QUERY` selects only identity/confidence for list rows, adds +presentation fields to `best`, and conditionally selects diagnostic fields for +JSON. -Always select: +Always select for every candidate position: ```text -kind canonicalKey displayName description registry stars downloadsLastMonth -docsAvailable codeAvailable protected confidence +kind canonicalKey confidence ``` -Select only when `includeDetailedFields` is true: +Also select for `best` in terminal mode: ```text -packageName latestVersion repositoryUrl repositoryOwner repositoryName -downloadsTotal documentationUrl matchedAliases matchTier score reason +description stars downloadsLastMonth docsAvailable codeAvailable ``` -Never select `inspection`. Use mode-specific candidate schemas: always-selected +For JSON, select the best presentation fields on every candidate plus: + +```text +displayName registry packageName latestVersion repositoryUrl repositoryOwner +repositoryName downloadsTotal documentationUrl matchedAliases matchTier score +reason +``` + +Never select `protected` or `inspection`; protected membership already comes +from the containing `protectedMatches` list. Use mode-specific candidate schemas: always-selected non-null fields are required in both modes; conditionally selected non-null fields are required only in detailed mode. Model enum-like response fields as `z.string()` so a new backend enum value remains parseable. The formatter diff --git a/packages/core-internal/src/services/resolve-target-service.test.ts b/packages/core-internal/src/services/resolve-target-service.test.ts index f2d68e82..2bdcfc8d 100644 --- a/packages/core-internal/src/services/resolve-target-service.test.ts +++ b/packages/core-internal/src/services/resolve-target-service.test.ts @@ -14,9 +14,14 @@ import { createMockTokenProvider } from "./test-helpers.js"; const ENDPOINT = "https://pkgseer.dev"; -const COMPACT_CANDIDATE = { +const LIST_CANDIDATE = { kind: "PACKAGE", canonicalKey: "npm:express", + confidence: "EXACT", +}; + +const COMPACT_CANDIDATE = { + ...LIST_CANDIDATE, displayName: "express", description: "Fast web framework", registry: "NPM", @@ -24,8 +29,6 @@ const COMPACT_CANDIDATE = { downloadsLastMonth: 89_000_000, docsAvailable: true, codeAvailable: true, - protected: true, - confidence: "EXACT", }; const DETAILED_CANDIDATE = { @@ -96,23 +99,34 @@ describe("ResolveTargetServiceImpl", () => { includeDetailedFields: false, }); expect(request.query).toBe(RESOLVE_TARGET_QUERY); + expect(request.query).toContain(`best { + ...ResolveTargetListFields + ...ResolveTargetBestFields + ...ResolveTargetJsonFields @include(if: $includeDetailedFields) + }`); + expect(request.query).toContain(`protectedMatches { + ...ResolveTargetListFields + description @include(if: $includeDetailedFields)`); + expect(request.query).toContain(`candidates { + ...ResolveTargetListFields + description @include(if: $includeDetailedFields)`); + for (const field of ["kind", "canonicalKey", "confidence"]) { + expect(request.query).toContain(` ${field}\n`); + } for (const field of [ - "kind", - "canonicalKey", - "displayName", "description", - "registry", "stars", "downloadsLastMonth", "docsAvailable", "codeAvailable", - "protected", - "confidence", ]) { - expect(request.query).toContain(` ${field}\n`); - expect(request.query).not.toContain(`${field} @include`); + expect(request.query).toContain( + `${field} @include(if: $includeDetailedFields)`, + ); } for (const field of [ + "displayName", + "registry", "packageName", "latestVersion", "repositoryUrl", @@ -125,12 +139,19 @@ describe("ResolveTargetServiceImpl", () => { "score", "reason", ]) { - expect(request.query).toContain( - `${field} @include(if: $includeDetailedFields)`, - ); + expect(request.query).toContain(` ${field}\n`); } + expect(request.query).not.toContain("\n protected\n"); expect(request.query).not.toContain("inspection"); - expect(result.best).toEqual(COMPACT_CANDIDATE); + expect(result.best).toEqual({ + ...LIST_CANDIDATE, + description: "Fast web framework", + stars: 66_000, + downloadsLastMonth: 89_000_000, + docsAvailable: true, + codeAvailable: true, + }); + expect(result.candidates).toEqual([LIST_CANDIDATE]); }); it("fetches and parses detailed fields for JSON output", async () => { diff --git a/packages/core-internal/src/services/resolve-target-service.ts b/packages/core-internal/src/services/resolve-target-service.ts index a816f8ac..3c4cde04 100644 --- a/packages/core-internal/src/services/resolve-target-service.ts +++ b/packages/core-internal/src/services/resolve-target-service.ts @@ -32,7 +32,7 @@ export interface ResolveTargetParams { export interface ResolveTargetCandidate { kind: string; canonicalKey: string; - displayName: string; + displayName?: string; description?: string; registry?: string; packageName?: string; @@ -45,9 +45,8 @@ export interface ResolveTargetCandidate { downloadsTotal?: number; documentationUrl?: string; matchedAliases?: string[]; - docsAvailable: boolean; - codeAvailable: boolean; - protected: boolean; + docsAvailable?: boolean; + codeAvailable?: boolean; matchTier?: number; score?: number; confidence: string; @@ -66,21 +65,23 @@ export interface ResolveTargetService { resolveTarget(params: ResolveTargetParams): Promise; } -const compactCandidateSchema = z.object({ +const listCandidateSchema = z.object({ kind: z.string(), canonicalKey: z.string(), - displayName: z.string(), + confidence: z.string(), +}); + +const bestCandidateSchema = listCandidateSchema.extend({ description: z.string().nullable().optional(), - registry: z.string().nullable().optional(), stars: z.number().int().nullable().optional(), downloadsLastMonth: z.number().int().nullable().optional(), docsAvailable: z.boolean(), codeAvailable: z.boolean(), - protected: z.boolean(), - confidence: z.string(), }); -const detailedCandidateSchema = compactCandidateSchema.extend({ +const detailedCandidateSchema = bestCandidateSchema.extend({ + displayName: z.string(), + registry: z.string().nullable().optional(), packageName: z.string().nullable().optional(), latestVersion: z.string().nullable().optional(), repositoryUrl: z.string().nullable().optional(), @@ -99,11 +100,12 @@ const graphQLErrorSchema = z.object({ extensions: z.record(z.string(), z.unknown()).optional(), }); -function responseSchema( +function responseSchema( + bestSchema: Best, candidateSchema: Candidate, ) { const resultSchema = z.object({ - best: candidateSchema.nullable(), + best: bestSchema.nullable(), protectedMatches: z.array(candidateSchema), candidates: z.array(candidateSchema), ambiguous: z.boolean(), @@ -137,37 +139,62 @@ query ResolveTarget( intentHints: $intentHints limit: $limit ) { - best { ...ResolveTargetCandidateFields } - protectedMatches { ...ResolveTargetCandidateFields } - candidates { ...ResolveTargetCandidateFields } + best { + ...ResolveTargetListFields + ...ResolveTargetBestFields + ...ResolveTargetJsonFields @include(if: $includeDetailedFields) + } + protectedMatches { + ...ResolveTargetListFields + description @include(if: $includeDetailedFields) + stars @include(if: $includeDetailedFields) + downloadsLastMonth @include(if: $includeDetailedFields) + docsAvailable @include(if: $includeDetailedFields) + codeAvailable @include(if: $includeDetailedFields) + ...ResolveTargetJsonFields @include(if: $includeDetailedFields) + } + candidates { + ...ResolveTargetListFields + description @include(if: $includeDetailedFields) + stars @include(if: $includeDetailedFields) + downloadsLastMonth @include(if: $includeDetailedFields) + docsAvailable @include(if: $includeDetailedFields) + codeAvailable @include(if: $includeDetailedFields) + ...ResolveTargetJsonFields @include(if: $includeDetailedFields) + } ambiguous ambiguousReason } } -fragment ResolveTargetCandidateFields on TargetResolutionCandidate { +fragment ResolveTargetListFields on TargetResolutionCandidate { kind canonicalKey - displayName + confidence +} + +fragment ResolveTargetBestFields on TargetResolutionCandidate { description - registry stars downloadsLastMonth docsAvailable codeAvailable - protected - confidence - packageName @include(if: $includeDetailedFields) - latestVersion @include(if: $includeDetailedFields) - repositoryUrl @include(if: $includeDetailedFields) - repositoryOwner @include(if: $includeDetailedFields) - repositoryName @include(if: $includeDetailedFields) - downloadsTotal @include(if: $includeDetailedFields) - documentationUrl @include(if: $includeDetailedFields) - matchedAliases @include(if: $includeDetailedFields) - matchTier @include(if: $includeDetailedFields) - score @include(if: $includeDetailedFields) - reason @include(if: $includeDetailedFields) +} + +fragment ResolveTargetJsonFields on TargetResolutionCandidate { + displayName + registry + packageName + latestVersion + repositoryUrl + repositoryOwner + repositoryName + downloadsTotal + documentationUrl + matchedAliases + matchTier + score + reason }`; export class ResolveTargetServiceImpl implements ResolveTargetService { @@ -221,12 +248,11 @@ export class ResolveTargetServiceImpl implements ResolveTargetService { throw createPackageIntelligenceHttpError(response); } - const candidateSchema = params.includeDetailedFields - ? detailedCandidateSchema - : compactCandidateSchema; - const parsed = responseSchema(candidateSchema).safeParse( - response.parsedBody, - ); + const parsed = ( + params.includeDetailedFields + ? responseSchema(detailedCandidateSchema, detailedCandidateSchema) + : responseSchema(bestCandidateSchema, listCandidateSchema) + ).safeParse(response.parsedBody); if (!parsed.success) { throw new MalformedPackageIntelligenceResponseError( "Malformed response from the target-resolution service.", @@ -275,24 +301,26 @@ function buildVariables(params: ResolveTargetParams): Record { function normaliseCandidate( candidate: - | z.infer + | z.infer + | z.infer | z.infer, ): ResolveTargetCandidate { const result: ResolveTargetCandidate = { kind: candidate.kind, canonicalKey: candidate.canonicalKey, - displayName: candidate.displayName, - docsAvailable: candidate.docsAvailable, - codeAvailable: candidate.codeAvailable, - protected: candidate.protected, confidence: candidate.confidence, }; - assignDefined(result, "description", candidate.description); - assignDefined(result, "registry", candidate.registry); - assignDefined(result, "stars", candidate.stars); - assignDefined(result, "downloadsLastMonth", candidate.downloadsLastMonth); + if ("docsAvailable" in candidate) { + assignDefined(result, "description", candidate.description); + assignDefined(result, "stars", candidate.stars); + assignDefined(result, "downloadsLastMonth", candidate.downloadsLastMonth); + assignDefined(result, "docsAvailable", candidate.docsAvailable); + assignDefined(result, "codeAvailable", candidate.codeAvailable); + } if ("matchedAliases" in candidate) { + assignDefined(result, "displayName", candidate.displayName); + assignDefined(result, "registry", candidate.registry); assignDefined(result, "packageName", candidate.packageName); assignDefined(result, "latestVersion", candidate.latestVersion); assignDefined(result, "repositoryUrl", candidate.repositoryUrl); diff --git a/packages/mcp/src/shared/resolve-target-response.test.ts b/packages/mcp/src/shared/resolve-target-response.test.ts index cdd94b53..dd005c92 100644 --- a/packages/mcp/src/shared/resolve-target-response.test.ts +++ b/packages/mcp/src/shared/resolve-target-response.test.ts @@ -23,7 +23,6 @@ function candidate( matchedAliases: ["express"], docsAvailable: true, codeAvailable: true, - protected: true, matchTier: 0, score: 100, confidence: "EXACT", @@ -142,7 +141,6 @@ describe("formatResolveTargetTerminal", () => { kind: "REPOSITORY", canonicalKey: "github:expressjs/express", displayName: "expressjs/express", - protected: false, confidence: "HIGH", }); const output = formatResolveTargetTerminal( diff --git a/packages/mcp/src/shared/resolve-target-response.ts b/packages/mcp/src/shared/resolve-target-response.ts index c14c47e8..41c431ac 100644 --- a/packages/mcp/src/shared/resolve-target-response.ts +++ b/packages/mcp/src/shared/resolve-target-response.ts @@ -8,7 +8,7 @@ import { shellQuote } from "./shell-quote.js"; export interface ResolveTargetCandidatePayload { target: string; - name: string; + name?: string; kind: string; confidence: string; description?: string; @@ -23,8 +23,8 @@ export interface ResolveTargetCandidatePayload { downloadsTotal?: number; documentationUrl?: string; matchedAliases?: string[]; - docsAvailable: boolean; - codeAvailable: boolean; + docsAvailable?: boolean; + codeAvailable?: boolean; matchTier?: number; score?: number; reason?: string; @@ -66,12 +66,10 @@ function projectCandidate( ): ResolveTargetCandidatePayload { const payload: ResolveTargetCandidatePayload = { target: candidate.canonicalKey, - name: candidate.displayName, kind: candidate.kind.toLowerCase(), confidence: candidate.confidence.toLowerCase(), - docsAvailable: candidate.docsAvailable, - codeAvailable: candidate.codeAvailable, }; + assign(payload, "name", candidate.displayName); assign(payload, "description", candidate.description); assign(payload, "registry", candidate.registry?.toLowerCase()); assign(payload, "packageName", candidate.packageName); @@ -84,6 +82,8 @@ function projectCandidate( assign(payload, "downloadsTotal", candidate.downloadsTotal); assign(payload, "documentationUrl", candidate.documentationUrl); assign(payload, "matchedAliases", candidate.matchedAliases); + assign(payload, "docsAvailable", candidate.docsAvailable); + assign(payload, "codeAvailable", candidate.codeAvailable); assign(payload, "matchTier", candidate.matchTier); assign(payload, "score", candidate.score); assign(payload, "reason", candidate.reason); diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index f59c7376..9237d250 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -54,6 +54,7 @@ export const EXPECTED_TOP_LEVEL_COMMANDS = [ "languages", "feedback", "doctor", + "resolve", "search", "search-status", "code", @@ -476,6 +477,19 @@ async function assertUnauthenticatedBehavior(): Promise { "root help should produce stdout", ); assertRootHelpStructure(helpResult.stdout); + assert( + helpResult.stdout.includes("githits resolve express"), + "root help should include resolve in Getting started", + ); + + const resolveHelp = await runCliWithEnv(["resolve", "--help"], env); + assert(resolveHelp.exitCode === 0, "resolve help should succeed"); + assert( + resolveHelp.stdout.includes("--query and --intent-hint") && + resolveHelp.stdout.includes("Do not") && + resolveHelp.stdout.includes("include credentials"), + "resolve help should disclose query privacy guidance", + ); for (const command of ["init", "login"] as const) { const commandHelp = await runCliWithEnv([command, "--help"], env); @@ -526,6 +540,21 @@ async function assertUnauthenticatedBehavior(): Promise { "unauthenticated languages JSON envelope", ); + const resolveJson = await runCliWithEnv( + ["resolve", "express", "--json"], + env, + ); + assert(resolveJson.exitCode !== 0, "unauthenticated resolve should fail"); + assert( + resolveJson.stdout.trim() === "", + "unauthenticated resolve JSON should keep stdout clean", + ); + assert( + assertCleanErrorEnvelope(resolveJson.stderr, "unauthenticated resolve") + .code === "AUTH_REQUIRED", + "unauthenticated resolve should return AUTH_REQUIRED", + ); + const terminalResult = await runCliWithEnv(["languages", "python"], env); assert( terminalResult.exitCode !== 0, @@ -548,6 +577,17 @@ async function assertUnauthenticatedBehavior(): Promise { !authGuidance.includes("tool call"), "unauthenticated terminal probe used MCP-style auth guidance", ); + + const resolveTerminal = await runCliWithEnv(["resolve", "express"], env); + const resolveGuidance = `${resolveTerminal.stderr}\n${resolveTerminal.stdout}`; + assert( + resolveTerminal.exitCode !== 0, + "unauthenticated resolve should fail", + ); + assert( + resolveGuidance.includes("githits login"), + "unauthenticated resolve should include login guidance", + ); } finally { isolated.cleanup(); } @@ -628,6 +668,48 @@ async function assertLiveOrAuthRequired(): Promise { } async function runLiveSmoke(): Promise { + const resolveText = assertTerminalOutput( + await runCli(["resolve", "express"]), + "resolve terminal", + ); + assert( + resolveText.includes("npm:express") && + (resolveText.includes("Best:") || resolveText.includes("Top:")), + "resolve terminal missing ranked express target", + ); + + const resolveJson = assertJsonOutput( + await runCli([ + "resolve", + "express", + "--registry", + "npm", + "--prefer-kind", + "package", + "--intent-hint", + "web server", + "--query", + "web framework", + "--limit", + "3", + "--json", + ]), + "resolve json", + ); + assertRecord(resolveJson, "resolve json"); + assert( + typeof resolveJson.best === "string" && resolveJson.best === "npm:express", + "resolve json missing best npm target", + ); + assert( + Array.isArray(resolveJson.candidates), + "resolve json missing candidates", + ); + assert( + Array.isArray(resolveJson.protectedMatches), + "resolve json missing protected matches", + ); + const languagesText = assertTerminalOutput( await runCli(["languages", "python"]), "languages terminal", @@ -1118,6 +1200,12 @@ async function runLiveSmoke(): Promise { "pkg info invalid json error", "INVALID_ARGUMENT", ); + + assertJsonErrorCode( + await runCli(["resolve", " ", "--json"]), + "resolve empty name json error", + "INVALID_ARGUMENT", + ); } export async function main(argv = process.argv.slice(2)): Promise { diff --git a/src/commands/resolve.test.ts b/src/commands/resolve.test.ts index 4cc4da98..c81beb7f 100644 --- a/src/commands/resolve.test.ts +++ b/src/commands/resolve.test.ts @@ -300,6 +300,7 @@ describe("registerResolveCommand", () => { "--intent-hint", "--limit", "--json", + "--query and --intent-hint values are sent", "include credentials, personal data, private code", ]) { expect(help).toContain(value); diff --git a/src/commands/resolve.ts b/src/commands/resolve.ts index e640d794..418b8c7c 100644 --- a/src/commands/resolve.ts +++ b/src/commands/resolve.ts @@ -81,8 +81,9 @@ function collectIntentHint(value: string, previous: string[] = []): string[] { const DESCRIPTION = `Resolve a human-provided name to ranked package or GitHub repository targets. -The optional --query value is sent to the service as ranking context. Do not -include credentials, personal data, private code, or proprietary content.`; +The optional --query and --intent-hint values are sent to the service as ranking +context. Do not include credentials, personal data, private code, or proprietary +content in either option.`; export function registerResolveCommand(program: Command): Command { return program diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index cc20df7b..15c922b2 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -981,7 +981,6 @@ export const defaultResolveTargetResult: ResolveTargetResult = { downloadsLastMonth: 89_000_000, docsAvailable: true, codeAvailable: true, - protected: true, confidence: "EXACT", }, protectedMatches: [], From 8d24d3d0230adf879d3326ff403f27b37a8fab3d Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 3 Aug 2026 20:21:28 +0300 Subject: [PATCH 4/8] docs: record guava resolver mismatch Capture the production ranking contradiction found during Phase 1 dogfooding so it can be added to the backend evaluation corpus. --- docs/plans/resolve-target.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/plans/resolve-target.md b/docs/plans/resolve-target.md index 263ef8df..6e2382ad 100644 --- a/docs/plans/resolve-target.md +++ b/docs/plans/resolve-target.md @@ -351,7 +351,10 @@ evals. Plan that PR from Phase 1 usage rather than expanding this plan now. actual: ``` -(No entries yet.) +- [ ] 2026-08-03 `guava` (query/registries/preferred kind/intent hints: none) + expected: `maven:com.google.guava:guava`, not ambiguous + actual: `maven:com.github.ben-manes.caffeine:guava`, + `CLOSE_CANDIDATES`; Maven/package hints produced the same best ## Acceptance criteria From ca0ca851bdaeb65ccb24704ab682d7670c9012ba Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 4 Aug 2026 09:18:47 +0300 Subject: [PATCH 5/8] docs: clarify guava hint behavior Record that package-kind constraints do not break the production tie while Google-specific task context selects the expected artifact. --- docs/plans/resolve-target.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/plans/resolve-target.md b/docs/plans/resolve-target.md index 6e2382ad..e20d65c0 100644 --- a/docs/plans/resolve-target.md +++ b/docs/plans/resolve-target.md @@ -354,7 +354,8 @@ evals. Plan that PR from Phase 1 usage rather than expanding this plan now. - [ ] 2026-08-03 `guava` (query/registries/preferred kind/intent hints: none) expected: `maven:com.google.guava:guava`, not ambiguous actual: `maven:com.github.ben-manes.caffeine:guava`, - `CLOSE_CANDIDATES`; Maven/package hints produced the same best + `CLOSE_CANDIDATES`; Maven/package hints produced the same best; + Google-specific query/intent hints selected the expected target ## Acceptance criteria From abf155bcc7dc35befe90e3e14071438f71e4e2ad Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 4 Aug 2026 09:45:16 +0300 Subject: [PATCH 6/8] feat: enrich resolve candidate evidence Show descriptions and available popularity/trust signals for every candidate while keeping inspection off the hot path. Harden terminal rendering for unknown backend values and control sequences. --- docs/implementation/cli-commands.md | 14 +++- docs/plans/resolve-target.md | 35 ++++---- .../services/resolve-target-service.test.ts | 33 ++++---- .../src/services/resolve-target-service.ts | 46 +++-------- .../shared/resolve-target-response.test.ts | 72 ++++++++++++++++- .../mcp/src/shared/resolve-target-response.ts | 81 +++++++++++++++---- src/commands/resolve.test.ts | 18 ++++- 7 files changed, 209 insertions(+), 90 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 2fdfec73..ff8c7171 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -198,8 +198,12 @@ Resolves a human-provided package or GitHub repository name to ranked canonical targets such as `npm:express` or `github:openai/codex`. The default output is a compact best/top candidate block, ambiguity guidance when needed, protected exact-name matches, alternatives, and a copyable `githits search --in` -follow-up. No candidates is a valid JSON/text result but exits 1 because the -command did not resolve a target. +follow-up. Every candidate includes its available description and cheap trust +evidence: repository stars, monthly or total package downloads, and docs/code +availability. When package repository popularity is unavailable, its linked +repository URL is shown as fallback evidence. Missing evidence is omitted +rather than shown as zero. No candidates is a valid JSON/text result but exits +1 because the command did not resolve a target. `--registry` accepts a comma-separated package-registry list; repository candidates remain eligible. `--prefer-kind package|repository` is a soft @@ -216,6 +220,12 @@ Detailed ranking fields are fetched only for JSON. Null fields are omitted and enum values are lowercase. Errors use the standard JSON envelope on stderr with clean stdout. +The current resolver candidate contract does not propagate linked GitHub +stars/forks/issues onto package candidates, so Maven packages can show their +repository URL but not its popularity. The backend request is documented in +`docs/sharing/PKGSEER_RESOLVE_CANDIDATE_TRUST_METRICS.md`; the CLI deliberately +does not select expensive per-candidate `inspection` metadata. + ### Proxy Support CLI-originated HTTP traffic uses `src/services/proxy-fetch.ts`. This includes OAuth discovery, client registration, token exchange/refresh, REST API calls, code/package service calls, local MCP tool calls started through `githits mcp start`, and npm update checks. The fetch factory supports `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` plus lowercase aliases; lowercase values win when both cases are set, matching undici's env proxy precedence. diff --git a/docs/plans/resolve-target.md b/docs/plans/resolve-target.md index e20d65c0..e53f2af7 100644 --- a/docs/plans/resolve-target.md +++ b/docs/plans/resolve-target.md @@ -71,6 +71,9 @@ containing `resolve` until all of these are true: optimizes it, or explicitly launches with fuzzy retrieval disabled. 4. Backend rate limiting and GraphQL complexity are confirmed adequate for the expected call volume. +5. PkgSeer exposes linked-repository trust metrics cheaply for package + candidates, or we explicitly accept shipping without them. See + `docs/sharing/PKGSEER_RESOLVE_CANDIDATE_TRUST_METRICS.md`. Resolver defects found during dogfooding go in the findings log at the end of this file with exact input, hints, expected result, actual result, and date. @@ -148,28 +151,21 @@ validation must preserve the standard terminal/JSON error envelopes. ## Wire contract -`RESOLVE_TARGET_QUERY` selects only identity/confidence for list rows, adds -presentation fields to `best`, and conditionally selects diagnostic fields for -JSON. +`RESOLVE_TARGET_QUERY` selects identity plus decision evidence for every row and +conditionally selects diagnostic fields for JSON. Always select for every candidate position: ```text -kind canonicalKey confidence -``` - -Also select for `best` in terminal mode: - -```text -description stars downloadsLastMonth docsAvailable codeAvailable +kind canonicalKey confidence description repositoryUrl stars +downloadsLastMonth downloadsTotal docsAvailable codeAvailable ``` For JSON, select the best presentation fields on every candidate plus: ```text -displayName registry packageName latestVersion repositoryUrl repositoryOwner -repositoryName downloadsTotal documentationUrl matchedAliases matchTier score -reason +displayName registry packageName latestVersion repositoryOwner repositoryName +documentationUrl matchedAliases matchTier score reason ``` Never select `protected` or `inspection`; protected membership already comes @@ -197,12 +193,12 @@ resolveTarget(params) Default terminal output is compact and scannable: ```text -Best: npm:express [exact] · package · 66k stars · 89M downloads/mo · docs · code +Best: npm:express [exact] · package · 497M downloads/mo · docs Fast, unopinionated, minimalist web framework Also consider: - github:expressjs/express [high] · repository - npm:express-validator [medium] · package + github:expressjs/express [exact] · repository · 69k stars · code + Fast, unopinionated, minimalist web framework Next: githits search '' --in npm:express ``` @@ -216,8 +212,11 @@ Rules: - Render protected matches excluding `best` in `Protected exact-name matches`. Render other ranked candidates excluding `best` and all protected keys in `Also consider`. Preserve backend order and first occurrence. -- Show one normalized, single-line best description capped at 120 characters. - Alternative rows do not repeat descriptions. +- Show each candidate's normalized description capped at 120 characters. +- Show available stars, monthly downloads (or total downloads when monthly is + absent), and docs/code availability. When a package has no repository + popularity, show its linked repository URL as fallback evidence. Never render + missing popularity as zero. - Reuse `formatCompactNumber`, colors, `shellQuote`, and canonical keys. If `--query` was supplied, the `Next` command uses it; otherwise it contains the literal `` placeholder. Repository and package targets use the same diff --git a/packages/core-internal/src/services/resolve-target-service.test.ts b/packages/core-internal/src/services/resolve-target-service.test.ts index 2bdcfc8d..5bfddede 100644 --- a/packages/core-internal/src/services/resolve-target-service.test.ts +++ b/packages/core-internal/src/services/resolve-target-service.test.ts @@ -25,8 +25,11 @@ const COMPACT_CANDIDATE = { displayName: "express", description: "Fast web framework", registry: "NPM", + latestVersion: "5.1.0", + repositoryUrl: "https://github.com/expressjs/express", stars: 66_000, downloadsLastMonth: 89_000_000, + downloadsTotal: null, docsAvailable: true, codeAvailable: true, }; @@ -34,11 +37,8 @@ const COMPACT_CANDIDATE = { const DETAILED_CANDIDATE = { ...COMPACT_CANDIDATE, packageName: "express", - latestVersion: "5.1.0", - repositoryUrl: "https://github.com/expressjs/express", repositoryOwner: "expressjs", repositoryName: "express", - downloadsTotal: null, documentationUrl: "https://expressjs.com", matchedAliases: ["express"], matchTier: 0, @@ -101,34 +101,35 @@ describe("ResolveTargetServiceImpl", () => { expect(request.query).toBe(RESOLVE_TARGET_QUERY); expect(request.query).toContain(`best { ...ResolveTargetListFields - ...ResolveTargetBestFields ...ResolveTargetJsonFields @include(if: $includeDetailedFields) }`); expect(request.query).toContain(`protectedMatches { ...ResolveTargetListFields - description @include(if: $includeDetailedFields)`); + ...ResolveTargetJsonFields @include(if: $includeDetailedFields)`); expect(request.query).toContain(`candidates { ...ResolveTargetListFields - description @include(if: $includeDetailedFields)`); - for (const field of ["kind", "canonicalKey", "confidence"]) { - expect(request.query).toContain(` ${field}\n`); - } + ...ResolveTargetJsonFields @include(if: $includeDetailedFields)`); for (const field of [ + "kind", + "canonicalKey", + "confidence", "description", + "repositoryUrl", "stars", "downloadsLastMonth", + "downloadsTotal", "docsAvailable", "codeAvailable", ]) { - expect(request.query).toContain( - `${field} @include(if: $includeDetailedFields)`, - ); + expect(request.query).toContain(` ${field}\n`); + expect(request.query).not.toContain(`${field} @include`); } for (const field of [ "displayName", "registry", "packageName", "latestVersion", + "latestVersion", "repositoryUrl", "repositoryOwner", "repositoryName", @@ -143,15 +144,17 @@ describe("ResolveTargetServiceImpl", () => { } expect(request.query).not.toContain("\n protected\n"); expect(request.query).not.toContain("inspection"); - expect(result.best).toEqual({ + const compactResult = { ...LIST_CANDIDATE, description: "Fast web framework", + repositoryUrl: "https://github.com/expressjs/express", stars: 66_000, downloadsLastMonth: 89_000_000, docsAvailable: true, codeAvailable: true, - }); - expect(result.candidates).toEqual([LIST_CANDIDATE]); + }; + expect(result.best).toEqual(compactResult); + expect(result.candidates).toEqual([compactResult]); }); it("fetches and parses detailed fields for JSON output", async () => { diff --git a/packages/core-internal/src/services/resolve-target-service.ts b/packages/core-internal/src/services/resolve-target-service.ts index 3c4cde04..4291e67f 100644 --- a/packages/core-internal/src/services/resolve-target-service.ts +++ b/packages/core-internal/src/services/resolve-target-service.ts @@ -69,25 +69,22 @@ const listCandidateSchema = z.object({ kind: z.string(), canonicalKey: z.string(), confidence: z.string(), -}); - -const bestCandidateSchema = listCandidateSchema.extend({ description: z.string().nullable().optional(), + repositoryUrl: z.string().nullable().optional(), stars: z.number().int().nullable().optional(), downloadsLastMonth: z.number().int().nullable().optional(), + downloadsTotal: z.number().int().nullable().optional(), docsAvailable: z.boolean(), codeAvailable: z.boolean(), }); -const detailedCandidateSchema = bestCandidateSchema.extend({ +const detailedCandidateSchema = listCandidateSchema.extend({ displayName: z.string(), registry: z.string().nullable().optional(), packageName: z.string().nullable().optional(), latestVersion: z.string().nullable().optional(), - repositoryUrl: z.string().nullable().optional(), repositoryOwner: z.string().nullable().optional(), repositoryName: z.string().nullable().optional(), - downloadsTotal: z.number().int().nullable().optional(), documentationUrl: z.string().nullable().optional(), matchedAliases: z.array(z.string()), matchTier: z.number().int(), @@ -141,25 +138,14 @@ query ResolveTarget( ) { best { ...ResolveTargetListFields - ...ResolveTargetBestFields ...ResolveTargetJsonFields @include(if: $includeDetailedFields) } protectedMatches { ...ResolveTargetListFields - description @include(if: $includeDetailedFields) - stars @include(if: $includeDetailedFields) - downloadsLastMonth @include(if: $includeDetailedFields) - docsAvailable @include(if: $includeDetailedFields) - codeAvailable @include(if: $includeDetailedFields) ...ResolveTargetJsonFields @include(if: $includeDetailedFields) } candidates { ...ResolveTargetListFields - description @include(if: $includeDetailedFields) - stars @include(if: $includeDetailedFields) - downloadsLastMonth @include(if: $includeDetailedFields) - docsAvailable @include(if: $includeDetailedFields) - codeAvailable @include(if: $includeDetailedFields) ...ResolveTargetJsonFields @include(if: $includeDetailedFields) } ambiguous @@ -171,12 +157,11 @@ fragment ResolveTargetListFields on TargetResolutionCandidate { kind canonicalKey confidence -} - -fragment ResolveTargetBestFields on TargetResolutionCandidate { description + repositoryUrl stars downloadsLastMonth + downloadsTotal docsAvailable codeAvailable } @@ -186,10 +171,8 @@ fragment ResolveTargetJsonFields on TargetResolutionCandidate { registry packageName latestVersion - repositoryUrl repositoryOwner repositoryName - downloadsTotal documentationUrl matchedAliases matchTier @@ -251,7 +234,7 @@ export class ResolveTargetServiceImpl implements ResolveTargetService { const parsed = ( params.includeDetailedFields ? responseSchema(detailedCandidateSchema, detailedCandidateSchema) - : responseSchema(bestCandidateSchema, listCandidateSchema) + : responseSchema(listCandidateSchema, listCandidateSchema) ).safeParse(response.parsedBody); if (!parsed.success) { throw new MalformedPackageIntelligenceResponseError( @@ -302,7 +285,6 @@ function buildVariables(params: ResolveTargetParams): Record { function normaliseCandidate( candidate: | z.infer - | z.infer | z.infer, ): ResolveTargetCandidate { const result: ResolveTargetCandidate = { @@ -311,22 +293,20 @@ function normaliseCandidate( confidence: candidate.confidence, }; - if ("docsAvailable" in candidate) { - assignDefined(result, "description", candidate.description); - assignDefined(result, "stars", candidate.stars); - assignDefined(result, "downloadsLastMonth", candidate.downloadsLastMonth); - assignDefined(result, "docsAvailable", candidate.docsAvailable); - assignDefined(result, "codeAvailable", candidate.codeAvailable); - } + assignDefined(result, "description", candidate.description); + assignDefined(result, "repositoryUrl", candidate.repositoryUrl); + assignDefined(result, "stars", candidate.stars); + assignDefined(result, "downloadsLastMonth", candidate.downloadsLastMonth); + assignDefined(result, "downloadsTotal", candidate.downloadsTotal); + assignDefined(result, "docsAvailable", candidate.docsAvailable); + assignDefined(result, "codeAvailable", candidate.codeAvailable); if ("matchedAliases" in candidate) { assignDefined(result, "displayName", candidate.displayName); assignDefined(result, "registry", candidate.registry); assignDefined(result, "packageName", candidate.packageName); assignDefined(result, "latestVersion", candidate.latestVersion); - assignDefined(result, "repositoryUrl", candidate.repositoryUrl); assignDefined(result, "repositoryOwner", candidate.repositoryOwner); assignDefined(result, "repositoryName", candidate.repositoryName); - assignDefined(result, "downloadsTotal", candidate.downloadsTotal); assignDefined(result, "documentationUrl", candidate.documentationUrl); assignDefined(result, "matchedAliases", candidate.matchedAliases); assignDefined(result, "matchTier", candidate.matchTier); diff --git a/packages/mcp/src/shared/resolve-target-response.test.ts b/packages/mcp/src/shared/resolve-target-response.test.ts index dd005c92..30bb4a6f 100644 --- a/packages/mcp/src/shared/resolve-target-response.test.ts +++ b/packages/mcp/src/shared/resolve-target-response.test.ts @@ -141,6 +141,7 @@ describe("formatResolveTargetTerminal", () => { kind: "REPOSITORY", canonicalKey: "github:expressjs/express", displayName: "expressjs/express", + downloadsLastMonth: undefined, confidence: "HIGH", }); const output = formatResolveTargetTerminal( @@ -152,21 +153,49 @@ describe("formatResolveTargetTerminal", () => { ); expect(output).toContain( - "Protected exact-name matches:\n pypi:express [exact] · package", + "Protected exact-name matches:\n pypi:express [exact] · package · 66k stars · 89M downloads/mo · docs · code\n Fast web framework", ); expect(output).toContain( - "Also consider:\n github:expressjs/express [high] · repository", + "Also consider:\n github:expressjs/express [high] · repository · 66k stars · docs · code\n Fast web framework", ); expect(output).toContain("githits search ''"); }); + it("shows total downloads or a linked repository when monthly downloads are unavailable", () => { + const crates = candidate({ + canonicalKey: "crates:serde", + stars: undefined, + downloadsLastMonth: undefined, + downloadsTotal: 500_000_000, + repositoryUrl: "https://github.com/serde-rs/serde/", + }); + const maven = candidate({ + canonicalKey: "maven:com.google.guava:guava", + stars: undefined, + downloadsLastMonth: undefined, + downloadsTotal: undefined, + repositoryUrl: "https://github.com/google/guava", + description: "Google core libraries for Java", + }); + const output = formatResolveTargetTerminal( + result({ candidates: [candidate(), crates, maven] }), + { name: "libraries", useColors: false }, + ); + + expect(output).toContain("crates:serde [exact] · package · 500M downloads"); + expect(output).toContain( + "maven:com.google.guava:guava [exact] · package · repo github.com/google/guava", + ); + expect(output).toContain(" Google core libraries for Java"); + }); + it("renders specific ambiguity guidance and Top wording", () => { const messages = { DUPLICATE_EXACT_NAME: "multiple exact package names match; narrow with --registry", CLOSE_CANDIDATES: "top candidates are equally plausible", LOW_CONFIDENCE: "only low-confidence matches were found", - NEW_REASON: "resolver reported new_reason", + NEW_REASON: "review the candidates below before use", }; for (const [ambiguousReason, message] of Object.entries(messages)) { const output = formatResolveTargetTerminal( @@ -199,6 +228,43 @@ describe("formatResolveTargetTerminal", () => { expect(description).toEndWith("..."); }); + it("uses generic terminal wording for unknown confidence and kind values", () => { + const drifted = candidate({ confidence: "VERY_HIGH", kind: "WORKSPACE" }); + const output = formatResolveTargetTerminal( + result({ best: drifted, candidates: [drifted], protectedMatches: [] }), + { name: "express", useColors: false }, + ); + expect(output).toContain("Top: npm:express [unknown] · target"); + expect(output).not.toContain("very_high"); + expect(output).not.toContain("workspace"); + }); + + it("strips terminal control sequences from backend-provided text", () => { + const hostile = candidate({ + canonicalKey: "npm:x\u001b[31m", + description: + "safe\u001b]8;;https://evil.test\u0007click\u001b]8;;\u0007 \u009bred \u0007bell \rreturn", + }); + const output = formatResolveTargetTerminal( + result({ best: hostile, candidates: [hostile], protectedMatches: [] }), + { name: "express", useColors: false }, + ); + expect(output).not.toContain("\u001b"); + expect(output).not.toContain("\u0007"); + expect(output).not.toContain("\u009b"); + expect(output).not.toContain("\r"); + expect(output).toContain("Best: npm:x ["); + expect(output).toContain("--in 'npm:x'"); + expect(output).toContain("safeclick red bell return"); + + expect( + formatResolveTargetTerminal( + result({ best: undefined, candidates: [], protectedMatches: [] }), + { name: "\u001b]0;owned\u0007missing", useColors: false }, + ), + ).toBe("No targets found for 'missing'.\n"); + }); + it("renders no-result text and optional ANSI colors", () => { expect( formatResolveTargetTerminal( diff --git a/packages/mcp/src/shared/resolve-target-response.ts b/packages/mcp/src/shared/resolve-target-response.ts index 41c431ac..0140b558 100644 --- a/packages/mcp/src/shared/resolve-target-response.ts +++ b/packages/mcp/src/shared/resolve-target-response.ts @@ -101,7 +101,9 @@ export function formatResolveTargetTerminal( result: ResolveTargetResult, options: FormatResolveTargetTerminalOptions, ): string { - if (!result.best) return `No targets found for '${options.name}'.\n`; + if (!result.best) { + return `No targets found for '${sanitizeTerminalText(options.name)}'.\n`; + } const useColors = options.useColors ?? false; const lines: string[] = []; if (result.ambiguous) lines.push(ambiguityMessage(result.ambiguousReason)); @@ -111,7 +113,7 @@ export function formatResolveTargetTerminal( ? "Best" : "Top"; lines.push( - `${colorize(`${bestLabel}:`, "green", useColors)} ${formatCandidate(result.best, useColors, true)}`, + `${colorize(`${bestLabel}:`, "green", useColors)} ${formatCandidate(result.best, useColors)}`, ); const description = compactDescription(result.best.description); if (description) lines.push(` ${dim(description, useColors)}`); @@ -123,8 +125,8 @@ export function formatResolveTargetTerminal( if (protectedMatches.length > 0) { lines.push("", "Protected exact-name matches:"); lines.push( - ...protectedMatches.map( - (candidate) => ` ${formatCandidate(candidate, useColors, false)}`, + ...protectedMatches.flatMap((candidate) => + formatCandidateLines(candidate, useColors), ), ); } @@ -141,42 +143,73 @@ export function formatResolveTargetTerminal( if (alternatives.length > 0) { lines.push("", "Also consider:"); lines.push( - ...alternatives.map( - (candidate) => ` ${formatCandidate(candidate, useColors, false)}`, + ...alternatives.flatMap((candidate) => + formatCandidateLines(candidate, useColors), ), ); } - const query = options.query?.trim() || ""; + const query = sanitizeTerminalText(options.query?.trim() || ""); lines.push( "", - `Next: githits search ${shellQuote(query)} --in ${shellQuote(result.best.canonicalKey)}`, + `Next: githits search ${shellQuote(query)} --in ${shellQuote(sanitizeTerminalText(result.best.canonicalKey))}`, ); return `${lines.join("\n")}\n`; } +const KNOWN_CONFIDENCE_VALUES = new Set(["exact", "high", "medium", "low"]); +const KNOWN_KIND_VALUES = new Set(["package", "repository"]); + function formatCandidate( candidate: ResolveTargetCandidate, useColors: boolean, - detailed: boolean, ): string { + const confidence = candidate.confidence.toLowerCase(); + const kind = candidate.kind.toLowerCase(); const fields = [ - `${highlight(candidate.canonicalKey, useColors)} [${candidate.confidence.toLowerCase()}]`, - candidate.kind.toLowerCase(), + `${highlight(sanitizeTerminalText(candidate.canonicalKey), useColors)} [${ + KNOWN_CONFIDENCE_VALUES.has(confidence) ? confidence : "unknown" + }]`, + KNOWN_KIND_VALUES.has(kind) ? kind : "target", ]; - if (detailed && candidate.stars !== undefined) { + if (candidate.stars !== undefined) { fields.push(`${formatCompactNumber(candidate.stars)} stars`); } - if (detailed && candidate.downloadsLastMonth !== undefined) { + if (candidate.downloadsLastMonth !== undefined) { fields.push( `${formatCompactNumber(candidate.downloadsLastMonth)} downloads/mo`, ); + } else if (candidate.downloadsTotal !== undefined) { + fields.push(`${formatCompactNumber(candidate.downloadsTotal)} downloads`); + } + if ( + kind === "package" && + candidate.repositoryUrl && + candidate.stars === undefined + ) { + fields.push(`repo ${compactRepositoryUrl(candidate.repositoryUrl)}`); } - if (detailed && candidate.docsAvailable) fields.push("docs"); - if (detailed && candidate.codeAvailable) fields.push("code"); + if (candidate.docsAvailable) fields.push("docs"); + if (candidate.codeAvailable) fields.push("code"); return fields.join(" · "); } +function formatCandidateLines( + candidate: ResolveTargetCandidate, + useColors: boolean, +): string[] { + const lines = [` ${formatCandidate(candidate, useColors)}`]; + const description = compactDescription(candidate.description); + if (description) lines.push(` ${dim(description, useColors)}`); + return lines; +} + +function compactRepositoryUrl(value: string): string { + return sanitizeTerminalText(value) + .replace(/^https?:\/\//i, "") + .replace(/\/$/, ""); +} + function ambiguityMessage(reason: string): string { switch (reason) { case "DUPLICATE_EXACT_NAME": @@ -186,18 +219,32 @@ function ambiguityMessage(reason: string): string { case "LOW_CONFIDENCE": return "Ambiguous: only low-confidence matches were found; review before use."; default: - return `Ambiguous: resolver reported ${reason.toLowerCase()}; review before use.`; + return "Ambiguous: review the candidates below before use."; } } function compactDescription(value: string | undefined): string | undefined { - const normalized = value?.replace(/\s+/g, " ").trim(); + const normalized = sanitizeTerminalText( + (value ?? "").replace(/\s+/g, " "), + ).trim(); if (!normalized) return undefined; return normalized.length > 120 ? `${normalized.slice(0, 117).trimEnd()}...` : normalized; } +const ESC = String.fromCharCode(0x1b); +// Whole ANSI CSI/OSC/two-byte escape sequences, then any remaining C0/C1/DEL +// control characters that could re-style or spoof the caller's terminal. +const TERMINAL_CONTROL_PATTERN = new RegExp( + `${ESC}(?:\\[[0-?]*[ -/]*[@-~]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\)?|[@-_])|[\\u0000-\\u001f\\u007f-\\u009f]`, + "g", +); + +function sanitizeTerminalText(value: string): string { + return value.replace(TERMINAL_CONTROL_PATTERN, ""); +} + function dedupeCandidates( candidates: ResolveTargetCandidate[], ): ResolveTargetCandidate[] { diff --git a/src/commands/resolve.test.ts b/src/commands/resolve.test.ts index c81beb7f..7adb7758 100644 --- a/src/commands/resolve.test.ts +++ b/src/commands/resolve.test.ts @@ -1,4 +1,12 @@ -import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; import { PackageIntelligenceFeatureFlagRequiredError } from "@githits/core-internal"; import { Command } from "commander"; import { @@ -22,8 +30,14 @@ function deps( }; } +let originalExitCode: typeof process.exitCode; + +beforeEach(() => { + originalExitCode = process.exitCode; +}); + afterEach(() => { - process.exitCode = 0; + process.exitCode = originalExitCode; mock.restore(); }); From 0c36e0a0dbe8f79f1ffd193317b1cadae13f849f Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 10 Aug 2026 14:14:56 +0300 Subject: [PATCH 7/8] feat: refine resolve terminal output Render ranked targets as a neutral numbered candidate list with inline trust evidence, canonical repository labels, and safe ambiguous follow-ups. Record the approved size exception and the separate repository-wide terminal sanitization plan. --- docs/implementation/cli-commands.md | 22 +- docs/plans/resolve-target.md | 51 ++-- docs/plans/terminal-text-sanitization.md | 217 ++++++++++++++++++ .../shared/resolve-target-response.test.ts | 71 ++++-- .../mcp/src/shared/resolve-target-response.ts | 77 +++---- scripts/cli-smoke.ts | 6 +- src/commands/resolve.test.ts | 4 +- 7 files changed, 352 insertions(+), 96 deletions(-) create mode 100644 docs/plans/terminal-text-sanitization.md diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index ff8c7171..5f0e1a01 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -196,14 +196,20 @@ githits resolve "pi agent" --query "coding agent CLI" --json Resolves a human-provided package or GitHub repository name to ranked canonical targets such as `npm:express` or `github:openai/codex`. The default output is a -compact best/top candidate block, ambiguity guidance when needed, protected -exact-name matches, alternatives, and a copyable `githits search --in` -follow-up. Every candidate includes its available description and cheap trust -evidence: repository stars, monthly or total package downloads, and docs/code -availability. When package repository popularity is unavailable, its linked -repository URL is shown as fallback evidence. Missing evidence is omitted -rather than shown as zero. No candidates is a valid JSON/text result but exits -1 because the command did not resolve a target. +compact numbered `Candidates` list with ambiguity guidance when needed and +protected exact-name matches annotated inline. It does not label any terminal +candidate as best or top. Every candidate includes its available normalized +description, capped at 240 characters, and cheap trust evidence: repository +stars, monthly or total package downloads, and docs/code availability. When +package repository popularity is unavailable, its linked repository is shown +as a canonical `github:owner/repo` fallback. Missing evidence is omitted rather +than shown as zero. + +The copyable `githits search --in` follow-up uses the resolved target only for +non-ambiguous results. Ambiguous results use the literal `` placeholder +so the terminal does not imply that candidate 1 was selected. No candidates is +a valid JSON/text result but exits 1 because the command did not resolve a +target. `--registry` accepts a comma-separated package-registry list; repository candidates remain eligible. `--prefer-kind package|repository` is a soft diff --git a/docs/plans/resolve-target.md b/docs/plans/resolve-target.md index e53f2af7..cac164ae 100644 --- a/docs/plans/resolve-target.md +++ b/docs/plans/resolve-target.md @@ -75,6 +75,15 @@ containing `resolve` until all of these are true: candidates, or we explicitly accept shipping without them. See `docs/sharing/PKGSEER_RESOLVE_CANDIDATE_TRUST_METRICS.md`. +The branch reached 2,651 insertions across 21 files against the plan's rough +1,500-line stop-and-re-slice gate. On 2026-08-10 the size exception was +explicitly approved after review found no unnecessary runtime machinery: +approximately 1,100 lines are tests and 421 are documentation, while splitting +the remaining service and CLI contracts would create dependent review slices. +This exception applies only to this completed resolver increment. The verified +repository-wide terminal control-sequence gap remains a separate increment in +`docs/plans/terminal-text-sanitization.md`. + Resolver defects found during dogfooding go in the findings log at the end of this file with exact input, hints, expected result, actual result, and date. Move each finding into the backend `cases.json` corpus, then remove its log @@ -193,34 +202,34 @@ resolveTarget(params) Default terminal output is compact and scannable: ```text -Best: npm:express [exact] · package · 497M downloads/mo · docs - Fast, unopinionated, minimalist web framework - -Also consider: - github:expressjs/express [exact] · repository · 69k stars · code - Fast, unopinionated, minimalist web framework +Candidates: + 1. npm:express [exact] · package · 497M downloads/mo · docs · protected exact-name match + Fast, unopinionated, minimalist web framework + 2. github:expressjs/express [exact] · repository · 69k stars · code + Fast, unopinionated, minimalist web framework Next: githits search '' --in npm:express ``` Rules: -- Use `Best` only for non-ambiguous `EXACT`/`HIGH`; otherwise use `Top`. +- Render one numbered `Candidates` list without `Best`, `Top`, or separate + alternative sections. Preserve backend candidate order, then append protected + matches and `best` only when absent, deduplicating by kind and canonical key. - If ambiguous, print one plain-language line before the result. Give specific guidance for duplicate exact names (`--registry`), close candidates, and low confidence; unknown reasons get neutral generic wording. -- Render protected matches excluding `best` in `Protected exact-name matches`. - Render other ranked candidates excluding `best` and all protected keys in - `Also consider`. Preserve backend order and first occurrence. -- Show each candidate's normalized description capped at 120 characters. +- Annotate protected exact-name candidates inline without changing list order. +- Show each candidate's normalized description capped at 240 characters. - Show available stars, monthly downloads (or total downloads when monthly is absent), and docs/code availability. When a package has no repository - popularity, show its linked repository URL as fallback evidence. Never render - missing popularity as zero. + popularity, show its linked repository as canonical `github:owner/repo` + fallback evidence. Never render missing popularity as zero. - Reuse `formatCompactNumber`, colors, `shellQuote`, and canonical keys. If `--query` was supplied, the `Next` command uses it; otherwise it contains the - literal `` placeholder. Repository and package targets use the same - valid `search --in` follow-up. + literal `` placeholder. Ambiguous output uses a literal `` + placeholder rather than implicitly selecting candidate 1. Repository and + package targets use the same valid `search --in` follow-up. - In text mode, no candidates prints `No targets found for ''.`; in JSON mode, emit the empty envelope below. Both exit 1. @@ -291,9 +300,9 @@ Register `resolve` unconditionally with lightweight commands and add it to root lowercase-to-GraphQL enum conversion, strict integer/range validation, and normalized wire params. - Response/terminal: stable JSON shape, null omission, lowercase/unknown enums, - all ambiguity reasons, best/top wording, protected overlap partitioning, + all ambiguity reasons, neutral numbered candidates, inline protected markers, unbounded protected extras and JSON reference closure, no candidates, - 120-character description, scoped target and quoted-query follow-ups, ANSI + 240-character description, scoped target and quoted-query follow-ups, ANSI on/off. - Command: auth before service call, text and JSON success, detailed-mode service flag, stdout/stderr discipline, mapped errors, no-result `exitCode = 1` @@ -320,9 +329,11 @@ bun run smoke:cli:built bun run smoke:mcp:built ``` -Target size: roughly 1.2-1.5k changed lines including tests and docs. If the -implementation requires a new generic GraphQL executor or exceeds this budget, -stop and re-slice rather than broadening the refactor. +The original target was roughly 1.2-1.5k changed lines including tests and docs, +with a stop-and-re-slice gate for a new generic GraphQL executor or broader +refactor. The completed increment exceeded that target under the explicit size +exception documented in `Scope and release gate`; no generic executor or +additional runtime machinery was introduced. ## Not handling diff --git a/docs/plans/terminal-text-sanitization.md b/docs/plans/terminal-text-sanitization.md new file mode 100644 index 00000000..3efd705c --- /dev/null +++ b/docs/plans/terminal-text-sanitization.md @@ -0,0 +1,217 @@ +# Plan: Untrusted text control-sequence sanitization + +## Goal + +Prevent untrusted backend or caller-provided metadata from emitting ANSI, OSC, +C0, C1, or DEL control sequences through GitHits human/agent text formatters. +Preserve normal Unicode and printable text, existing formatter-owned ANSI +colors, JSON payloads, and raw source/document content contracts. + +This work follows the local `sanitizeTerminalText` fix added for `githits +resolve`. It starts on a fresh branch after the resolver increment so the +approved resolver size exception does not grow further. + +## Verified issue + +`packages/mcp/src/shared/resolve-target-response.ts` strips complete CSI, OSC, +and two-byte escape sequences followed by residual C0/C1/DEL controls from +candidate descriptions, canonical keys, repository links, command arguments, +and no-result names. Regression coverage proved that unsanitized values could +emit terminal styling, fake hyperlinks, title changes, and line rewriting. + +The same class of untrusted metadata is rendered without equivalent sanitization +elsewhere: + +- package summary identity, description, repository metadata, topics, + advisories, and recent changes; +- vulnerability identities, summaries, aliases, ranges, fixes, and upgrade + paths; +- dependency names, versions, constraints, groups, conflicts, and importers; +- upgrade-review deprecation, compatibility, advisory, changelog, and dependency + evidence; +- changelog versions, URLs, headings, and rendered preview lines; +- caller/request echoes such as requested versions, lifecycle/filter values, + changelog addressing, repository URLs, and version ranges; +- code/docs/search metadata, mapped errors, and language names. + +The package formatters are shared by CLI and MCP text surfaces. Removing control +sequences from metadata on both surfaces is intentional: these controls are not +semantic package data, and downstream MCP clients may display text directly. +Structured JSON remains unchanged and continues to preserve backend strings via +JSON escaping. + +## Assumptions + +1. Backend, registry, and caller-provided metadata is untrusted even when it + passed request or transport schema validation; schemas validate shape, not + terminal safety. +2. Control characters have no valid meaning in package identity or metadata + fields. Newlines used for layout are owned by formatters, not backend values. +3. Raw file contents, documentation bodies, and grep source lines have a + different contract: callers may redirect or otherwise consume them as source + content. They cannot use the metadata sanitizer without an explicit product + decision. +4. Removing controls from shared package text output is a security hardening + change, not a reason to alter tool schemas or public TypeScript APIs. + +## High-level split + +### PR 1: Package-intelligence text metadata + +Extract the proven sanitizer and apply it to package-intelligence text +formatters. This is the next detailed increment and should remain below roughly +1,500 changed lines including tests and docs. Measure the delta before review; +if it approaches the limit, stop and split by package formatter rather than +weakening field coverage or tests. + +### Later direction: Code, docs, search, and CLI errors + +Apply the same field-level rule to code/docs metadata, search/status output, +language names, mapped service errors, and command-specific terminal errors. +Before planning that PR, re-inventory the exact call sites after PR 1 and decide +how raw-content commands should communicate their terminal risk without +changing round-trip content. Do not design a raw-content mode or new flag in PR +1. + +## PR 1 design + +### Shared helper + +Move the existing regex and `sanitizeTerminalText(value: string): string` into +`packages/mcp/src/shared/terminal-text.ts`. Keep it package-internal in PR 1; do +not export it through `packages/mcp/src/index.ts`, `packages/mcp/src/internal.ts`, +or the public package export map. The later root CLI slice may expose it through +the workspace-only internal entrypoint when that concrete caller exists. + +The helper remains a pure string transform. It strips complete ANSI CSI/OSC and +two-byte escape sequences before residual C0/C1/DEL controls so payload text +cannot survive as partial terminal instructions. It does not normalize, +truncate, wrap, quote, or filter printable content; those remain formatter +responsibilities. + +No configurable mode, recursive object sanitizer, output stream wrapper, or +global hook is added. Field-level calls are more explicit and avoid corrupting +formatter-owned colors or raw content. + +### Formatter integration + +Replace the resolver-local helper with the shared helper without changing +resolver output. Then derive sanitized local display values for all untrusted +backend and caller/request strings in these shared package formatters: + +- `package-summary-response.ts`; +- `package-vulnerabilities-response.ts`; +- `package-dependencies-response.ts`; +- `package-upgrade-review-response.ts`; +- `package-changelog-response.ts`. + +Cover identity fields, descriptions, URLs, topics, advisory text, versions, +ranges, constraints, deprecation/compatibility text, dependency evidence, +changelog headings, and caller/request echoes such as requested versions, +filters, addressing, and version ranges. Sanitize each local display value after +semantic shaping but before display-oriented wrapping, width measurement, +truncation, padding, interpolation, or formatter-owned ANSI coloring. This keeps +layout calculations free of invisible hostile bytes without mutating shared +payloads. Multiline body previews are the explicit ordering exception: preserve +their existing line split first, then sanitize each untrusted line before any +per-line display transformation or formatter-owned indentation. Do not sanitize +a completed output string because that would remove formatter-owned ANSI colors +and line structure. + +Keep payload builders and JSON formatters untouched. Do not change schemas, +fetch selections, response types, wrapping widths, truncation, ordering, or +normal output wording. + +### Documentation + +Add the text-output trust boundary to `docs/implementation/TOOL_GUARDRAILS.md`: +backend, registry, and caller/request metadata is untrusted data, +formatter-owned layout is trusted, and raw content has an explicit preservation +exception. Update package formatter implementation docs only where they describe +exact output contracts. + +### Release boundary + +This changes both the root CLI and public `@githits/mcp` package tool text. Bump +the root `githits` patch version and keep `server.json`, `.plugin/plugin.json`, +`.claude-plugin/plugin.json`, `plugins/claude/.claude-plugin/plugin.json`, +`.claude-plugin/marketplace.json`, and `gemini-extension.json` aligned. Bump the +`@githits/mcp` patch version in `packages/mcp/package.json`; for a new root minor, +follow the coordinated-release rule and start the MCP package at `X.Y.0`. +Include the text-output security hardening in release notes. Review public Agent +Skills for wording impact, but do not change them unless their documented output +behavior is now inaccurate. + +## Tests + +1. Add focused helper tests covering CSI, OSC terminated by BEL and ST, + two-byte escapes, residual C0/C1/DEL controls, incomplete sequences, benign + Unicode, and ordinary printable text. +2. Keep the existing resolver hostile-text regression and change only its + import path if needed. +3. Add one hostile metadata integration case per package formatter. Populate + every rendered untrusted string category across compact and verbose paths, + including caller/request echoes. With colors disabled, remove or account for + expected formatter-owned line breaks before proving no untrusted escape or + control characters remain; separately assert that expected line structure and + ordinary text are unchanged. With colors enabled, prove hostile sequences are + absent and only expected formatter-owned SGR sequences and line breaks remain. +4. Add changelog/upgrade-review coverage proving backend preview lines are + sanitized while formatter-owned line structure remains intact. +5. Add a JSON regression proving corresponding backend and caller-derived + strings remain present in structured output; sanitization belongs only to + text rendering. +6. Assert public package declarations and manifests do not expose the helper or + private aliases. + +Use existing fixtures and formatter tests. Do not add a sanitizer parity harness +or broad snapshots. + +## Verification + +Run: + +```text +bun test +bun test +bun run typecheck +bun run format:check +bun run lint +bun run build +(cd packages/mcp && bun run build) +bun run validate:packages +bun run validate:packages:mcp-publish +bun run smoke:cli +bun run smoke:mcp +bun run smoke:cli:built +bun run smoke:mcp:built +``` + +Because shared formatter behavior reaches MCP package tools, run targeted +package workloads from `bun run agent:e2e` and inspect `tool-calls.json` and +`final.json` for unchanged normal usability. Live smoke rate limits are an +external failure only when the affected package probes completed first and the +failure is recorded exactly. + +## Not handling + +- Raw source-file bodies, documentation bodies, and grep source lines: altering + them would break explicit content-preservation and redirection contracts. +- Generated example markdown: it is primary content rather than metadata and + needs the same separate product decision as raw source/document bodies. +- JSON strings: `JSON.stringify` already produces safe transport encoding, and + structured consumers require faithful backend values. +- Prompt-injection or prose-policy filtering: this increment addresses terminal + control sequences only; printable third-party content remains data governed + by existing tool guardrails. +- New CLI flags, output modes, stream wrappers, recursive sanitizers, or backend + validation: none is required to fix the verified metadata rendering flaw. +- Code/docs/search/error metadata: verified but reserved for the next planned + slice to keep PR 1 within the complexity budget. + +## Completion + +After PR 1 ships, transfer its durable trust-boundary rules to implementation +documentation, update this file with evidence needed to scope the next slice, +and remove completed PR 1 implementation detail. Delete this plan when all +retained work has moved to implementation documentation or a fresh active plan. diff --git a/packages/mcp/src/shared/resolve-target-response.test.ts b/packages/mcp/src/shared/resolve-target-response.test.ts index 30bb4a6f..c5877c7b 100644 --- a/packages/mcp/src/shared/resolve-target-response.test.ts +++ b/packages/mcp/src/shared/resolve-target-response.test.ts @@ -116,7 +116,7 @@ describe("buildResolveTargetSuccessPayload", () => { }); describe("formatResolveTargetTerminal", () => { - it("renders a compact best result and copyable supplied-query follow-up", () => { + it("renders a compact candidate list and copyable supplied-query follow-up", () => { const output = formatResolveTargetTerminal(result(), { name: "express", query: "router's middleware", @@ -124,15 +124,15 @@ describe("formatResolveTargetTerminal", () => { }); expect(output).toContain( - "Best: npm:express [exact] · package · 66k stars · 89M downloads/mo · docs · code", + "Candidates:\n 1. npm:express [exact] · package · 66k stars · 89M downloads/mo · docs · code · protected exact-name match", ); - expect(output).toContain(" Fast web framework"); + expect(output).toContain(" Fast web framework"); expect(output).toContain( `Next: githits search 'router'"'"'s middleware' --in 'npm:express'`, ); }); - it("partitions protected matches from ranked alternatives", () => { + it("renders protected matches inline without changing candidate order", () => { const protectedExtra = candidate({ canonicalKey: "pypi:express", registry: "PYPI", @@ -153,14 +153,46 @@ describe("formatResolveTargetTerminal", () => { ); expect(output).toContain( - "Protected exact-name matches:\n pypi:express [exact] · package · 66k stars · 89M downloads/mo · docs · code\n Fast web framework", + "1. npm:express [exact] · package · 66k stars · 89M downloads/mo · docs · code · protected exact-name match", ); expect(output).toContain( - "Also consider:\n github:expressjs/express [high] · repository · 66k stars · docs · code\n Fast web framework", + "2. pypi:express [exact] · package · 66k stars · 89M downloads/mo · docs · code · protected exact-name match", ); + expect(output).toContain( + "3. github:expressjs/express [high] · repository · 66k stars · docs · code", + ); + expect(output).not.toContain("Also consider:"); + expect(output).not.toContain("Protected exact-name matches:"); expect(output).toContain("githits search ''"); }); + it("appends missing protected and best candidates after ranked candidates", () => { + const protectedExtra = candidate({ + canonicalKey: "pypi:express", + registry: "PYPI", + }); + const best = candidate({ + kind: "REPOSITORY", + canonicalKey: "github:expressjs/express", + displayName: "expressjs/express", + registry: undefined, + }); + const output = formatResolveTargetTerminal( + result({ + best, + protectedMatches: [candidate(), protectedExtra], + candidates: [candidate()], + }), + { name: "express", useColors: false }, + ); + + expect(output.match(/^ {2}\d+\. \S+/gm)).toEqual([ + " 1. npm:express", + " 2. pypi:express", + " 3. github:expressjs/express", + ]); + }); + it("shows total downloads or a linked repository when monthly downloads are unavailable", () => { const crates = candidate({ canonicalKey: "crates:serde", @@ -184,12 +216,12 @@ describe("formatResolveTargetTerminal", () => { expect(output).toContain("crates:serde [exact] · package · 500M downloads"); expect(output).toContain( - "maven:com.google.guava:guava [exact] · package · repo github.com/google/guava", + "maven:com.google.guava:guava [exact] · package · repo github:google/guava", ); - expect(output).toContain(" Google core libraries for Java"); + expect(output).toContain(" Google core libraries for Java"); }); - it("renders specific ambiguity guidance and Top wording", () => { + it("renders specific ambiguity guidance and a generic follow-up target", () => { const messages = { DUPLICATE_EXACT_NAME: "multiple exact package names match; narrow with --registry", @@ -203,28 +235,31 @@ describe("formatResolveTargetTerminal", () => { { name: "express", useColors: false }, ); expect(output).toContain(`Ambiguous: ${message}`); - expect(output).toContain("Top: npm:express"); + expect(output).toContain("Candidates:\n 1. npm:express"); + expect(output).toContain( + "Next after choosing: githits search '' --in ''", + ); } }); - it("uses Top for a non-ambiguous medium result", () => { + it("does not add recommendation wording for a medium result", () => { const medium = candidate({ confidence: "MEDIUM" }); expect( formatResolveTargetTerminal( result({ best: medium, candidates: [medium], protectedMatches: [] }), { name: "express", useColors: false }, ), - ).toContain("Top: npm:express"); + ).toContain("Candidates:\n 1. npm:express"); }); - it("normalizes and caps the best description at 120 characters", () => { - const long = candidate({ description: `first\n${"x".repeat(150)}` }); + it("normalizes and caps candidate descriptions at 240 characters", () => { + const long = candidate({ description: `first\n${"x".repeat(300)}` }); const output = formatResolveTargetTerminal( result({ best: long, candidates: [long] }), { name: "express", useColors: false }, ); - const description = output.split("\n")[1]?.trim() ?? ""; - expect(description.length).toBe(120); + const description = output.split("\n")[2]?.trim() ?? ""; + expect(description.length).toBe(240); expect(description).toEndWith("..."); }); @@ -234,7 +269,7 @@ describe("formatResolveTargetTerminal", () => { result({ best: drifted, candidates: [drifted], protectedMatches: [] }), { name: "express", useColors: false }, ); - expect(output).toContain("Top: npm:express [unknown] · target"); + expect(output).toContain("1. npm:express [unknown] · target"); expect(output).not.toContain("very_high"); expect(output).not.toContain("workspace"); }); @@ -253,7 +288,7 @@ describe("formatResolveTargetTerminal", () => { expect(output).not.toContain("\u0007"); expect(output).not.toContain("\u009b"); expect(output).not.toContain("\r"); - expect(output).toContain("Best: npm:x ["); + expect(output).toContain("1. npm:x ["); expect(output).toContain("--in 'npm:x'"); expect(output).toContain("safeclick red bell return"); diff --git a/packages/mcp/src/shared/resolve-target-response.ts b/packages/mcp/src/shared/resolve-target-response.ts index 0140b558..833c2c3c 100644 --- a/packages/mcp/src/shared/resolve-target-response.ts +++ b/packages/mcp/src/shared/resolve-target-response.ts @@ -2,8 +2,9 @@ import type { ResolveTargetCandidate, ResolveTargetResult, } from "@githits/core-internal"; -import { colorize, dim, highlight } from "./colors.js"; +import { dim, highlight } from "./colors.js"; import { formatCompactNumber } from "./format-number.js"; +import { formatRepositoryTarget } from "./repository-target.js"; import { shellQuote } from "./shell-quote.js"; export interface ResolveTargetCandidatePayload { @@ -107,52 +108,33 @@ export function formatResolveTargetTerminal( const useColors = options.useColors ?? false; const lines: string[] = []; if (result.ambiguous) lines.push(ambiguityMessage(result.ambiguousReason)); - - const bestLabel = - !result.ambiguous && ["EXACT", "HIGH"].includes(result.best.confidence) - ? "Best" - : "Top"; - lines.push( - `${colorize(`${bestLabel}:`, "green", useColors)} ${formatCandidate(result.best, useColors)}`, - ); - const description = compactDescription(result.best.description); - if (description) lines.push(` ${dim(description, useColors)}`); - - const bestKey = candidateKey(result.best); - const protectedMatches = dedupeCandidates(result.protectedMatches).filter( - (candidate) => candidateKey(candidate) !== bestKey, - ); - if (protectedMatches.length > 0) { - lines.push("", "Protected exact-name matches:"); - lines.push( - ...protectedMatches.flatMap((candidate) => - formatCandidateLines(candidate, useColors), - ), - ); - } - const protectedKeys = new Set( result.protectedMatches.map((candidate) => candidateKey(candidate)), ); - const alternatives = dedupeCandidates(result.candidates).filter( - (candidate) => { - const key = candidateKey(candidate); - return key !== bestKey && !protectedKeys.has(key); - }, - ); - if (alternatives.length > 0) { - lines.push("", "Also consider:"); - lines.push( - ...alternatives.flatMap((candidate) => - formatCandidateLines(candidate, useColors), + const candidates = dedupeCandidates([ + ...result.candidates, + ...result.protectedMatches, + result.best, + ]); + lines.push("Candidates:"); + lines.push( + ...candidates.flatMap((candidate, index) => + formatCandidateLines( + candidate, + index + 1, + protectedKeys.has(candidateKey(candidate)), + useColors, ), - ); - } + ), + ); const query = sanitizeTerminalText(options.query?.trim() || ""); + const target = result.ambiguous + ? "" + : sanitizeTerminalText(result.best.canonicalKey); lines.push( "", - `Next: githits search ${shellQuote(query)} --in ${shellQuote(sanitizeTerminalText(result.best.canonicalKey))}`, + `${result.ambiguous ? "Next after choosing" : "Next"}: githits search ${shellQuote(query)} --in ${shellQuote(target)}`, ); return `${lines.join("\n")}\n`; } @@ -196,18 +178,21 @@ function formatCandidate( function formatCandidateLines( candidate: ResolveTargetCandidate, + index: number, + protectedMatch: boolean, useColors: boolean, ): string[] { - const lines = [` ${formatCandidate(candidate, useColors)}`]; + const marker = protectedMatch ? " · protected exact-name match" : ""; + const lines = [ + ` ${index}. ${formatCandidate(candidate, useColors)}${marker}`, + ]; const description = compactDescription(candidate.description); - if (description) lines.push(` ${dim(description, useColors)}`); + if (description) lines.push(` ${dim(description, useColors)}`); return lines; } function compactRepositoryUrl(value: string): string { - return sanitizeTerminalText(value) - .replace(/^https?:\/\//i, "") - .replace(/\/$/, ""); + return sanitizeTerminalText(formatRepositoryTarget(value)); } function ambiguityMessage(reason: string): string { @@ -228,8 +213,8 @@ function compactDescription(value: string | undefined): string | undefined { (value ?? "").replace(/\s+/g, " "), ).trim(); if (!normalized) return undefined; - return normalized.length > 120 - ? `${normalized.slice(0, 117).trimEnd()}...` + return normalized.length > 240 + ? `${normalized.slice(0, 237).trimEnd()}...` : normalized; } diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 9237d250..6897b061 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -673,9 +673,9 @@ async function runLiveSmoke(): Promise { "resolve terminal", ); assert( - resolveText.includes("npm:express") && - (resolveText.includes("Best:") || resolveText.includes("Top:")), - "resolve terminal missing ranked express target", + resolveText.includes("Candidates:") && + /\n\s+\d+\. npm:express/.test(resolveText), + "resolve terminal missing numbered express candidate", ); const resolveJson = assertJsonOutput( diff --git a/src/commands/resolve.test.ts b/src/commands/resolve.test.ts index 7adb7758..27668827 100644 --- a/src/commands/resolve.test.ts +++ b/src/commands/resolve.test.ts @@ -73,7 +73,9 @@ describe("resolveAction", () => { limit: 3, includeDetailedFields: false, }); - expect(String(writeSpy.mock.calls[0]?.[0])).toContain("Best: npm:express"); + expect(String(writeSpy.mock.calls[0]?.[0])).toContain( + "Candidates:\n 1. npm:express", + ); }); it("requests detailed data and prints clean JSON", async () => { From cf760b5ced5779a814e153206eb28a214d1f730e Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 10 Aug 2026 14:21:56 +0300 Subject: [PATCH 8/8] docs: link resolver trust metrics issue Replace local feature-request references with the durable PkgSeer backend issue so the resolver release gate remains actionable after merge. --- docs/implementation/cli-commands.md | 5 +++-- docs/plans/resolve-target.md | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 5f0e1a01..27c797ee 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -229,8 +229,9 @@ clean stdout. The current resolver candidate contract does not propagate linked GitHub stars/forks/issues onto package candidates, so Maven packages can show their repository URL but not its popularity. The backend request is documented in -`docs/sharing/PKGSEER_RESOLVE_CANDIDATE_TRUST_METRICS.md`; the CLI deliberately -does not select expensive per-candidate `inspection` metadata. +[pkgseer-backend#1666](https://github.com/githits-com/pkgseer-backend/issues/1666); +the CLI deliberately does not select expensive per-candidate `inspection` +metadata. ### Proxy Support diff --git a/docs/plans/resolve-target.md b/docs/plans/resolve-target.md index cac164ae..24a0a0a1 100644 --- a/docs/plans/resolve-target.md +++ b/docs/plans/resolve-target.md @@ -72,8 +72,8 @@ containing `resolve` until all of these are true: 4. Backend rate limiting and GraphQL complexity are confirmed adequate for the expected call volume. 5. PkgSeer exposes linked-repository trust metrics cheaply for package - candidates, or we explicitly accept shipping without them. See - `docs/sharing/PKGSEER_RESOLVE_CANDIDATE_TRUST_METRICS.md`. + candidates, or we explicitly accept shipping without them. Tracked in + [pkgseer-backend#1666](https://github.com/githits-com/pkgseer-backend/issues/1666). The branch reached 2,651 insertions across 21 files against the plan's rough 1,500-line stop-and-re-slice gate. On 2026-08-10 the size exception was