diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index bfa95ac..27c797e 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,54 @@ 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 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 +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. + +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 +[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 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 new file mode 100644 index 0000000..24a0a0a --- /dev/null +++ b/docs/plans/resolve-target.md @@ -0,0 +1,383 @@ +# 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. +5. PkgSeer exposes linked-repository trust metrics cheaply for package + 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 +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 +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` 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`: + +- 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` 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 description repositoryUrl stars +downloadsLastMonth downloadsTotal docsAvailable codeAvailable +``` + +For JSON, select the best presentation fields on every candidate plus: + +```text +displayName registry packageName latestVersion repositoryOwner repositoryName +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 +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 +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: + +- 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. +- 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 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. 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. + +`--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, neutral numbered candidates, inline protected markers, + unbounded protected extras and JSON reference closure, no candidates, + 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` + (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 +``` + +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 + +- 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: +``` + +- [ ] 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; + Google-specific query/intent hints selected the expected target + +## 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/docs/plans/terminal-text-sanitization.md b/docs/plans/terminal-text-sanitization.md new file mode 100644 index 0000000..3efd705 --- /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/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 998c5b1..338947e 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 7c66dbc..6b491ec 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 0000000..5bfdded --- /dev/null +++ b/packages/core-internal/src/services/resolve-target-service.test.ts @@ -0,0 +1,363 @@ +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 LIST_CANDIDATE = { + kind: "PACKAGE", + canonicalKey: "npm:express", + confidence: "EXACT", +}; + +const COMPACT_CANDIDATE = { + ...LIST_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, +}; + +const DETAILED_CANDIDATE = { + ...COMPACT_CANDIDATE, + packageName: "express", + repositoryOwner: "expressjs", + repositoryName: "express", + 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); + expect(request.query).toContain(`best { + ...ResolveTargetListFields + ...ResolveTargetJsonFields @include(if: $includeDetailedFields) + }`); + expect(request.query).toContain(`protectedMatches { + ...ResolveTargetListFields + ...ResolveTargetJsonFields @include(if: $includeDetailedFields)`); + expect(request.query).toContain(`candidates { + ...ResolveTargetListFields + ...ResolveTargetJsonFields @include(if: $includeDetailedFields)`); + for (const field of [ + "kind", + "canonicalKey", + "confidence", + "description", + "repositoryUrl", + "stars", + "downloadsLastMonth", + "downloadsTotal", + "docsAvailable", + "codeAvailable", + ]) { + 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", + "downloadsTotal", + "documentationUrl", + "matchedAliases", + "matchTier", + "score", + "reason", + ]) { + expect(request.query).toContain(` ${field}\n`); + } + expect(request.query).not.toContain("\n protected\n"); + expect(request.query).not.toContain("inspection"); + 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.best).toEqual(compactResult); + expect(result.candidates).toEqual([compactResult]); + }); + + 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 0000000..4291e67 --- /dev/null +++ b/packages/core-internal/src/services/resolve-target-service.ts @@ -0,0 +1,325 @@ +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; + 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 listCandidateSchema = z.object({ + kind: z.string(), + canonicalKey: z.string(), + confidence: z.string(), + 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 = listCandidateSchema.extend({ + displayName: z.string(), + registry: z.string().nullable().optional(), + packageName: z.string().nullable().optional(), + latestVersion: z.string().nullable().optional(), + repositoryOwner: z.string().nullable().optional(), + repositoryName: z.string().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( + bestSchema: Best, + candidateSchema: Candidate, +) { + const resultSchema = z.object({ + best: bestSchema.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 { + ...ResolveTargetListFields + ...ResolveTargetJsonFields @include(if: $includeDetailedFields) + } + protectedMatches { + ...ResolveTargetListFields + ...ResolveTargetJsonFields @include(if: $includeDetailedFields) + } + candidates { + ...ResolveTargetListFields + ...ResolveTargetJsonFields @include(if: $includeDetailedFields) + } + ambiguous + ambiguousReason + } +} + +fragment ResolveTargetListFields on TargetResolutionCandidate { + kind + canonicalKey + confidence + description + repositoryUrl + stars + downloadsLastMonth + downloadsTotal + docsAvailable + codeAvailable +} + +fragment ResolveTargetJsonFields on TargetResolutionCandidate { + displayName + registry + packageName + latestVersion + repositoryOwner + repositoryName + documentationUrl + matchedAliases + matchTier + score + reason +}`; + +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 parsed = ( + params.includeDetailedFields + ? responseSchema(detailedCandidateSchema, detailedCandidateSchema) + : responseSchema(listCandidateSchema, listCandidateSchema) + ).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, + confidence: candidate.confidence, + }; + + 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, "repositoryOwner", candidate.repositoryOwner); + assignDefined(result, "repositoryName", candidate.repositoryName); + 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; +} diff --git a/packages/core-internal/src/shared/pkgseer-registry.test.ts b/packages/core-internal/src/shared/pkgseer-registry.test.ts index 8ad1a91..633c4c8 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 57d0886..79f6a1e 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 cda9dbb..f8b9fb9 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 0000000..a56ef57 --- /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 0000000..a2fa4fb --- /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 0000000..c5877c7 --- /dev/null +++ b/packages/mcp/src/shared/resolve-target-response.test.ts @@ -0,0 +1,317 @@ +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, + 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 candidate list and copyable supplied-query follow-up", () => { + const output = formatResolveTargetTerminal(result(), { + name: "express", + query: "router's middleware", + useColors: false, + }); + + expect(output).toContain( + "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( + `Next: githits search 'router'"'"'s middleware' --in 'npm:express'`, + ); + }); + + it("renders protected matches inline without changing candidate order", () => { + const protectedExtra = candidate({ + canonicalKey: "pypi:express", + registry: "PYPI", + }); + const alternative = candidate({ + kind: "REPOSITORY", + canonicalKey: "github:expressjs/express", + displayName: "expressjs/express", + downloadsLastMonth: undefined, + confidence: "HIGH", + }); + const output = formatResolveTargetTerminal( + result({ + protectedMatches: [candidate(), protectedExtra], + candidates: [candidate(), protectedExtra, alternative], + }), + { name: "express", useColors: false }, + ); + + expect(output).toContain( + "1. npm:express [exact] · package · 66k stars · 89M downloads/mo · docs · code · protected exact-name match", + ); + expect(output).toContain( + "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", + 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:google/guava", + ); + expect(output).toContain(" Google core libraries for Java"); + }); + + it("renders specific ambiguity guidance and a generic follow-up target", () => { + 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: "review the candidates below before use", + }; + 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("Candidates:\n 1. npm:express"); + expect(output).toContain( + "Next after choosing: githits search '' --in ''", + ); + } + }); + + 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("Candidates:\n 1. npm:express"); + }); + + 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")[2]?.trim() ?? ""; + expect(description.length).toBe(240); + 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("1. 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("1. 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( + 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 0000000..833c2c3 --- /dev/null +++ b/packages/mcp/src/shared/resolve-target-response.ts @@ -0,0 +1,255 @@ +import type { + ResolveTargetCandidate, + ResolveTargetResult, +} from "@githits/core-internal"; +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 { + 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, + kind: candidate.kind.toLowerCase(), + confidence: candidate.confidence.toLowerCase(), + }; + assign(payload, "name", candidate.displayName); + 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, "docsAvailable", candidate.docsAvailable); + assign(payload, "codeAvailable", candidate.codeAvailable); + 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 '${sanitizeTerminalText(options.name)}'.\n`; + } + const useColors = options.useColors ?? false; + const lines: string[] = []; + if (result.ambiguous) lines.push(ambiguityMessage(result.ambiguousReason)); + const protectedKeys = new Set( + result.protectedMatches.map((candidate) => candidateKey(candidate)), + ); + 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( + "", + `${result.ambiguous ? "Next after choosing" : "Next"}: githits search ${shellQuote(query)} --in ${shellQuote(target)}`, + ); + 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, +): string { + const confidence = candidate.confidence.toLowerCase(); + const kind = candidate.kind.toLowerCase(); + const fields = [ + `${highlight(sanitizeTerminalText(candidate.canonicalKey), useColors)} [${ + KNOWN_CONFIDENCE_VALUES.has(confidence) ? confidence : "unknown" + }]`, + KNOWN_KIND_VALUES.has(kind) ? kind : "target", + ]; + if (candidate.stars !== undefined) { + fields.push(`${formatCompactNumber(candidate.stars)} stars`); + } + 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 (candidate.docsAvailable) fields.push("docs"); + if (candidate.codeAvailable) fields.push("code"); + return fields.join(" · "); +} + +function formatCandidateLines( + candidate: ResolveTargetCandidate, + index: number, + protectedMatch: boolean, + useColors: boolean, +): string[] { + 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)}`); + return lines; +} + +function compactRepositoryUrl(value: string): string { + return sanitizeTerminalText(formatRepositoryTarget(value)); +} + +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: review the candidates below before use."; + } +} + +function compactDescription(value: string | undefined): string | undefined { + const normalized = sanitizeTerminalText( + (value ?? "").replace(/\s+/g, " "), + ).trim(); + if (!normalized) return undefined; + return normalized.length > 240 + ? `${normalized.slice(0, 237).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[] { + 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/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index f59c737..6897b06 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("Candidates:") && + /\n\s+\d+\. npm:express/.test(resolveText), + "resolve terminal missing numbered express candidate", + ); + + 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/cli.ts b/src/cli.ts index 59702c8..9e3b47a 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 a56b735..9645d21 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 0000000..2766882 --- /dev/null +++ b/src/commands/resolve.test.ts @@ -0,0 +1,325 @@ +import { + afterEach, + beforeEach, + 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, + }; +} + +let originalExitCode: typeof process.exitCode; + +beforeEach(() => { + originalExitCode = process.exitCode; +}); + +afterEach(() => { + process.exitCode = originalExitCode; + 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( + "Candidates:\n 1. 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", + "--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 new file mode 100644 index 0000000..418b8c7 --- /dev/null +++ b/src/commands/resolve.ts @@ -0,0 +1,115 @@ +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 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 + .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 c5737cb..2207b59 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 c97b2b1..088e327 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 f2529aa..15c922b 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,34 @@ 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, + 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. */