diff --git a/.please/docs/tracks.jsonl b/.please/docs/tracks.jsonl index 3ce7856..48cb8de 100644 --- a/.please/docs/tracks.jsonl +++ b/.please/docs/tracks.jsonl @@ -4,4 +4,5 @@ {"id":"registry-meta-20260407","type":"feature","status":"completed","phase":"implement","issue":"#5","created":"2026-04-07","section":"completed"} {"id":"ecosystem-resolvers-20260407","type":"feature","status":"completed","phase":"implement","issue":"#6","created":"2026-04-07","section":"completed"} {"id":"npm-publish-release-please-20260408","type":"chore","status":"planned","phase":"spec","issue":"#16","created":"2026-04-08","section":"active"} +{"id":"maven-resolver-20260408","type":"feature","status":"in_progress","phase":"implement","issue":"#15","created":"2026-04-08","section":"active"} {"id":"extract-shared-registry-20260408","type":"refactor","status":"in_progress","phase":"implement","issue":"#14","created":"2026-04-08","section":"active"} diff --git a/.please/docs/tracks/completed/maven-resolver-20260408/metadata.json b/.please/docs/tracks/completed/maven-resolver-20260408/metadata.json new file mode 100644 index 0000000..c74eb80 --- /dev/null +++ b/.please/docs/tracks/completed/maven-resolver-20260408/metadata.json @@ -0,0 +1,10 @@ +{ + "track_id": "maven-resolver-20260408", + "type": "feature", + "status": "review", + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z", + "issue": "#15", + "pr": "#17", + "project": "" +} diff --git a/.please/docs/tracks/completed/maven-resolver-20260408/plan.md b/.please/docs/tracks/completed/maven-resolver-20260408/plan.md new file mode 100644 index 0000000..d61b9df --- /dev/null +++ b/.please/docs/tracks/completed/maven-resolver-20260408/plan.md @@ -0,0 +1,149 @@ +# Plan: Maven Ecosystem Resolver + +> Track: maven-resolver-20260408 +> Spec: [spec.md](./spec.md) + +## Overview +- **Source**: /please:plan +- **Track**: maven-resolver-20260408 +- **Created**: 2026-04-08 +- **Approach**: Follow existing resolver pattern (NpmResolver/PypiResolver/PubResolver) + +## Purpose + +Add Maven Central support to the CLI resolver system, enabling `maven:groupId:artifactId` package resolution. This completes the Maven ecosystem support that currently only has registry aliases and auto-detection but no resolver. + +## Context + +The ASK CLI has three ecosystem resolvers (npm, pypi, pub) that map package identifiers to GitHub repositories. Maven auto-detection (`pom.xml` → `'maven'`) and registry aliases (`maven:group:artifact`) already exist, but `getResolver('maven')` returns `null`. Users with Maven projects get a "no resolver for 'maven'" error when the registry misses. + +## Architecture Decision + +**Approach**: Direct pattern replication with dual-source fallback. + +- Follow the established `EcosystemResolver` interface exactly as npm/pypi/pub do +- Primary source: Maven Central Search API (JSON, fast, includes `scmUrl`) +- Fallback source: Direct POM XML download from Maven Central Repository (regex extraction of `` and `` tags — no external XML parser needed) +- `name` parameter arrives as `groupId:artifactId` (split at last colon) +- POM path construction: `groupId.replace(/\./g, '/')` → `com/google/guava/guava/{version}/guava-{version}.pom` + +**Why no XML parser**: POM files have predictable structure; regex extraction of 2 tags is simpler and avoids adding a dependency. The `parseRepoUrl()` utility handles URL normalization. + +## Tasks + +### T-1: Create MavenResolver class with Search API lookup +- **File**: `packages/cli/src/resolvers/maven.ts` (new) +- **Test**: `packages/cli/test/resolvers/maven.test.ts` (new) +- **FR**: FR-1, FR-2, FR-3, FR-5 (Search API scmUrl), FR-6, FR-7 +- **Details**: + - Implement `MavenResolver` class with `resolve(name, version)` method + - Parse `name` as `groupId:artifactId` (split at last colon) + - Fetch `https://search.maven.org/solrsearch/select?q=g:{groupId}+AND+a:{artifactId}&rows=1&wt=json&core=gav` for latest + - For explicit version: add `&v={version}` filter + - Extract `response.docs[0].g`, `.a`, `.v`, `.repositoryUrl` or fall back to `scmUrl` from extended search + - Return `ResolveResult` with `v{version}` ref and `{version}` fallback +- **TDD**: RED → test mock Search API JSON → GREEN → implement resolver → REFACTOR + +### T-2: Add POM XML fallback for GitHub URL extraction +- **File**: `packages/cli/src/resolvers/maven.ts` +- **Test**: `packages/cli/test/resolvers/maven.test.ts` +- **FR**: FR-4, FR-5 (POM scm.url, POM url) +- **Details**: + - When Search API fails (non-200 or no scmUrl), download POM XML from `https://repo1.maven.org/maven2/{groupPath}/{artifactId}/{version}/{artifactId}-{version}.pom` + - Regex extract `` → `` value, fallback to top-level `` value + - Use `parseRepoUrl()` for normalization + - Requires resolved version — if version is `latest`, fetch latest version from Search API first, then use POM as fallback for repo URL only +- **TDD**: RED → test mock POM XML response → GREEN → implement fallback → REFACTOR +- **Depends on**: T-1 + +### T-3: Register MavenResolver in resolver index +- **File**: `packages/cli/src/resolvers/index.ts` +- **Test**: `packages/cli/test/resolvers/index.test.ts` +- **FR**: FR-8 +- **Details**: + - Add `'maven'` to `SupportedEcosystem` union type + - Add `maven: new MavenResolver()` to `resolvers` record + - Add test: `getResolver('maven')` returns defined resolver with `.resolve` function +- **TDD**: RED → add test for `getResolver('maven')` → GREEN → register → REFACTOR +- **Depends on**: T-1 + +### T-4: Integration verification and edge cases +- **File**: `packages/cli/test/resolvers/maven.test.ts` +- **FR**: FR-9, AC-1 through AC-6 +- **Details**: + - Test `maven:com.google.guava:guava` resolves correctly (AC-1) + - Test explicit version `@33.4.0-jre` (AC-2) + - Test Search API failure triggers POM fallback (AC-3) + - Test error when neither source has GitHub URL + - Test invalid `name` format (missing colon) throws clear error + - Verify all existing resolver tests still pass (AC-5) + - Ensure >80% coverage (AC-6) +- **Depends on**: T-1, T-2, T-3 + +## Dependencies + +``` +T-1 → T-2 +T-1 → T-3 +T-2 + T-3 → T-4 +``` + +## Key Files + +| File | Role | +|---|---| +| `packages/cli/src/resolvers/maven.ts` | New MavenResolver class | +| `packages/cli/src/resolvers/index.ts` | Register maven in SupportedEcosystem | +| `packages/cli/src/resolvers/utils.ts` | Shared `parseRepoUrl()` utility | +| `packages/cli/src/registry.ts` | `parseDocSpec()` + `detectEcosystem()` (no changes needed) | +| `packages/cli/test/resolvers/maven.test.ts` | New test file | +| `packages/cli/test/resolvers/index.test.ts` | Add maven registration test | + +## Verification + +```bash +bun test packages/cli/test/resolvers/maven.test.ts +bun test packages/cli/test/resolvers/index.test.ts +bun test packages/cli/test/resolvers/ # all resolver tests +bun run --cwd packages/cli lint +``` + +## Progress + +- [x] T-1: MavenResolver + Search API +- [x] T-2: POM XML fallback +- [x] T-3: Register in index +- [x] T-4: Integration tests + edge cases + +## Decision Log + +| Decision | Rationale | +|---|---| +| Regex over XML parser for POM | Only need 2 tags; avoids new dependency | +| Last-colon split for groupId:artifactId | groupId contains dots, artifactId never contains colons | +| Search API `core=gav` parameter | Returns version-specific results for explicit version lookup | + +## Surprises & Discoveries + +- `parseDocSpec` uses first colon as ecosystem separator, so `maven:com.google.guava:guava` correctly splits into `ecosystem=maven`, `name=com.google.guava:guava` +- Maven auto-detection already maps `pom.xml`/`build.gradle`/`build.gradle.kts` to `'maven'` ecosystem +- No changes needed to `registry.ts` or `index.ts` — the resolver system is fully pluggable + +## Outcomes & Retrospective + +### What Was Shipped +- `MavenResolver` class with Search API + POM XML dual-source architecture +- 18 unit tests covering happy paths, fallback scenarios, and edge cases +- Registered in resolver index — `getResolver('maven')` now works + +### What Went Well +- Existing resolver pattern made implementation straightforward +- TDD approach caught the scmUrl query core issue during review +- Spec compliance check identified missing POM fallback path early + +### What Could Improve +- Initial implementation missed the Search API scmUrl priority (FR-5) — should verify API response schema before implementing +- Maven Central Search API `core=gav` vs default core behavior should be documented in gotchas + +### Tech Debt Created +- None diff --git a/.please/docs/tracks/completed/maven-resolver-20260408/spec.md b/.please/docs/tracks/completed/maven-resolver-20260408/spec.md new file mode 100644 index 0000000..0b5c089 --- /dev/null +++ b/.please/docs/tracks/completed/maven-resolver-20260408/spec.md @@ -0,0 +1,49 @@ +# Maven Ecosystem Resolver + +> Track: maven-resolver-20260408 + +## Overview + +Add a Maven ecosystem resolver to the ASK CLI, enabling `maven:groupId:artifactId` spec format. The resolver maps Maven Central packages to their GitHub repositories, completing Maven ecosystem support alongside the existing registry aliases. + +## Requirements + +### Functional Requirements + +- [ ] FR-1: Implement `MavenResolver` class implementing `EcosystemResolver` interface with `resolve(name, version)` method +- [ ] FR-2: Support `groupId:artifactId` package identifier format (colon-separated, Maven standard) +- [ ] FR-3: Primary lookup via Maven Central Search API (`https://search.maven.org/solrsearch/select?q=g:GROUP+AND+a:ARTIFACT&rows=1&wt=json`) +- [ ] FR-4: Fallback to direct POM XML download from Maven Central Repository (`https://repo1.maven.org/maven2/{group/path}/{artifact}/{version}/{artifact}-{version}.pom`) when Search API fails +- [ ] FR-5: Extract GitHub repository URL with priority: (1) Search API `scmUrl` field, (2) POM `` element, (3) POM `` element. Use `parseRepoUrl()` utility for normalization +- [ ] FR-6: Version resolution — resolve `latest` to the most recent release version; support explicit version lookup via Search API `v:{version}` filter +- [ ] FR-7: Return `ResolveResult` with `repo`, `ref` (`v{version}` primary, `{version}` fallback), and `resolvedVersion` +- [ ] FR-8: Register `MavenResolver` in `resolvers/index.ts` — add `'maven'` to `SupportedEcosystem` union type and resolvers record +- [ ] FR-9: Verify integration with existing registry maven aliases (e.g., `maven:com.google.guava:guava` → registry lookup → resolver fallback) + +### Non-functional Requirements + +- [ ] NFR-1: Follow existing resolver patterns (NpmResolver, PypiResolver) for code structure and error handling +- [ ] NFR-2: Use `consola.debug()` for resolver trace logging +- [ ] NFR-3: Provide clear error messages when GitHub repo cannot be resolved from Maven metadata + +## Acceptance Criteria + +- [ ] AC-1: `ask docs add maven:com.google.guava:guava` resolves to `google/guava` GitHub repo with correct version tag +- [ ] AC-2: `ask docs add maven:com.google.guava:guava@33.4.0-jre` resolves to the specific version +- [ ] AC-3: When Search API is unavailable, POM XML fallback successfully resolves the package +- [ ] AC-4: `getResolver('maven')` returns a `MavenResolver` instance +- [ ] AC-5: All existing resolver tests continue to pass +- [ ] AC-6: Maven resolver has >80% test coverage + +## Out of Scope + +- Gradle-specific metadata or build file parsing +- Maven local repository (`~/.m2`) scanning +- Private/enterprise Maven repository support (e.g., Nexus, Artifactory) +- Multi-module Maven project resolution (only top-level artifact) + +## Assumptions + +- Maven Central Search API (`search.maven.org`) is publicly accessible without authentication +- Most popular Maven packages have a GitHub `scm.url` in their POM +- The `groupId:artifactId` colon format does not conflict with existing spec parsing (version separator is `@`) diff --git a/packages/cli/src/resolvers/index.ts b/packages/cli/src/resolvers/index.ts index 032a879..a5e93b0 100644 --- a/packages/cli/src/resolvers/index.ts +++ b/packages/cli/src/resolvers/index.ts @@ -1,3 +1,4 @@ +import { MavenResolver } from './maven.js' import { NpmResolver } from './npm.js' import { PubResolver } from './pub.js' import { PypiResolver } from './pypi.js' @@ -31,9 +32,10 @@ export interface EcosystemResolver { resolve: (name: string, version: string) => Promise } -type SupportedEcosystem = 'npm' | 'pypi' | 'pub' +type SupportedEcosystem = 'maven' | 'npm' | 'pypi' | 'pub' const resolvers: Record = { + maven: new MavenResolver(), npm: new NpmResolver(), pypi: new PypiResolver(), pub: new PubResolver(), diff --git a/packages/cli/src/resolvers/maven.ts b/packages/cli/src/resolvers/maven.ts new file mode 100644 index 0000000..be7db60 --- /dev/null +++ b/packages/cli/src/resolvers/maven.ts @@ -0,0 +1,255 @@ +import type { EcosystemResolver, ResolveResult } from './index.js' +import { consola } from 'consola' +import { parseRepoUrl } from './utils.js' + +/** + * Maven Central Search API response (partial — only fields we need). + */ +interface MavenSearchDoc { + g: string + a: string + v: string + repositoryId?: string + ec?: string[] +} + +interface MavenSearchResponse { + response: { + numFound: number + docs: MavenSearchDoc[] + } +} + +interface VersionResult { + version: string + scmUrl?: string +} + +const RE_SCM_URL = /[\s\S]*?([^<]+)<\/url>/ +const RE_PROJECT_URL = /]*>[\s\S]*?([^<]+)<\/url>/ + +/** + * Parse `groupId:artifactId` from the combined name string. + * Splits at the last colon — groupId may contain dots but never colons, + * while artifactId never contains colons. + */ +function parseMavenCoordinate(name: string): { groupId: string, artifactId: string } { + const lastColon = name.lastIndexOf(':') + if (lastColon <= 0 || lastColon === name.length - 1) { + throw new Error( + `Invalid Maven coordinate '${name}': expected 'groupId:artifactId' format (e.g. 'com.google.guava:guava')`, + ) + } + return { + groupId: name.substring(0, lastColon), + artifactId: name.substring(lastColon + 1), + } +} + +const RE_DOT = /\./g +const RE_RELEASE = /([^<]+)<\/release>/ +const RE_LATEST = /([^<]+)<\/latest>/ + +/** + * Build the Maven Central Repository POM URL. + * Converts groupId dots to path separators. + */ +function buildPomUrl(groupId: string, artifactId: string, version: string): string { + const groupPath = groupId.replace(RE_DOT, '/') + return `https://repo1.maven.org/maven2/${groupPath}/${artifactId}/${version}/${artifactId}-${version}.pom` +} + +/** + * Extract a GitHub repository URL from POM XML content. + * Priority: → top-level + */ +function extractRepoFromPom(pomXml: string): string | null { + // Try first + const scmMatch = RE_SCM_URL.exec(pomXml) + if (scmMatch) { + const repo = parseRepoUrl(scmMatch[1].trim()) + if (repo) + return repo + } + + // Fall back to top-level + const urlMatch = RE_PROJECT_URL.exec(pomXml) + if (urlMatch) { + const repo = parseRepoUrl(urlMatch[1].trim()) + if (repo) + return repo + } + + return null +} + +/** + * Resolve a Maven Central package to a GitHub repo + git tag. + * + * 1. Fetch latest version from Maven Central Search API + * 2. For explicit versions, use the version directly + * 3. Extract GitHub repo from Search API scmUrl, or fallback to POM XML + * 4. Return `v{version}` as the git ref with `{version}` fallback + */ +export class MavenResolver implements EcosystemResolver { + async resolve(name: string, version: string): Promise { + const { groupId, artifactId } = parseMavenCoordinate(name) + + // Step 1: Resolve version + optional scmUrl via Search API + const versionResult = await this.resolveVersion(groupId, artifactId, version) + const resolvedVersion = versionResult.version + + // Step 2: Find GitHub repo — priority: (1) Search API scmUrl, (2) POM , (3) POM + let repo: string | null = null + + // (1) Check Search API scmUrl first + if (versionResult.scmUrl) { + repo = parseRepoUrl(versionResult.scmUrl) + } + + // (2, 3) Fall back to POM XML + if (!repo) { + repo = await this.findRepoFromPom(groupId, artifactId, resolvedVersion) + } + + if (!repo) { + throw new Error( + `Cannot resolve GitHub repository for Maven package '${groupId}:${artifactId}'. ` + + `Neither the Search API nor the POM contains a GitHub URL. ` + + `Use 'owner/repo' format instead: ask docs add owner/repo`, + ) + } + + consola.debug(`maven: ${groupId}:${artifactId}@${version} → ${repo}@${resolvedVersion}`) + + return { + repo, + ref: `v${resolvedVersion}`, + fallbackRefs: [resolvedVersion], + resolvedVersion, + } + } + + /** + * Resolve version via Search API. Falls back to POM-based resolution + * when the Search API is unavailable. + * + * For explicit versions, the version is used as-is (no Search API needed + * for validation — the POM fetch will fail if it doesn't exist). + */ + private async resolveVersion(groupId: string, artifactId: string, version: string): Promise { + // Explicit versions can skip Search API for version resolution + if (version !== 'latest') { + // Still try Search API for scmUrl, but don't fail if unavailable + try { + return await this.fetchSearchApi(groupId, artifactId, version) + } + catch { + return { version } + } + } + + // For 'latest', try Search API first + try { + return await this.fetchSearchApi(groupId, artifactId, version) + } + catch { + // Search API unavailable — try maven-metadata.xml for latest version + consola.debug(`maven: Search API unavailable, trying maven-metadata.xml`) + return this.resolveVersionFromMetadata(groupId, artifactId) + } + } + + /** + * Fetch version and optional scmUrl from Maven Central Search API. + */ + private async fetchSearchApi(groupId: string, artifactId: string, version: string): Promise { + const isLatest = version === 'latest' + const query = isLatest + ? `q=g:${encodeURIComponent(groupId)}+AND+a:${encodeURIComponent(artifactId)}&rows=1&wt=json` + : `q=g:${encodeURIComponent(groupId)}+AND+a:${encodeURIComponent(artifactId)}+AND+v:${encodeURIComponent(version)}&rows=1&wt=json&core=gav` + + const url = `https://search.maven.org/solrsearch/select?${query}` + const response = await fetch(url) + + if (!response.ok) { + throw new Error( + `Maven Central Search API returned ${response.status} for ${groupId}:${artifactId}`, + ) + } + + const data = await response.json() as MavenSearchResponse + + if (data.response.numFound === 0) { + throw new Error( + `Maven package '${groupId}:${artifactId}'${isLatest ? '' : `@${version}`} not found on Maven Central`, + ) + } + + const doc = data.response.docs[0] + + // Try to get scmUrl from artifact-level search (default core, not GAV) + let scmUrl: string | undefined + try { + const extUrl = `https://search.maven.org/solrsearch/select?q=g:${encodeURIComponent(groupId)}+AND+a:${encodeURIComponent(artifactId)}&rows=1&wt=json` + const extResponse = await fetch(extUrl) + if (extResponse.ok) { + const extData = await extResponse.json() as { response: { docs: Array<{ 'scm.url'?: string }> } } + scmUrl = extData.response.docs[0]?.['scm.url'] + } + } + catch { + // Non-critical — will fall back to POM + } + + return { version: doc.v, scmUrl } + } + + /** + * Resolve latest version from maven-metadata.xml when Search API is unavailable. + */ + private async resolveVersionFromMetadata(groupId: string, artifactId: string): Promise { + const groupPath = groupId.replace(RE_DOT, '/') + const url = `https://repo1.maven.org/maven2/${groupPath}/${artifactId}/maven-metadata.xml` + + const response = await fetch(url) + if (!response.ok) { + throw new Error( + `Cannot resolve Maven package '${groupId}:${artifactId}': ` + + `Search API unavailable and maven-metadata.xml returned ${response.status}`, + ) + } + + const xml = await response.text() + const versionMatch = RE_RELEASE.exec(xml) ?? RE_LATEST.exec(xml) + + if (!versionMatch) { + throw new Error( + `Cannot resolve latest version for Maven package '${groupId}:${artifactId}': ` + + `no or tag in maven-metadata.xml`, + ) + } + + return { version: versionMatch[1] } + } + + /** + * Find GitHub repo URL from POM XML. Priority: + */ + private async findRepoFromPom(groupId: string, artifactId: string, version: string): Promise { + const pomUrl = buildPomUrl(groupId, artifactId, version) + + try { + const response = await fetch(pomUrl) + if (response.ok) { + const pomXml = await response.text() + return extractRepoFromPom(pomXml) + } + } + catch { + consola.debug(`maven: POM fetch failed for ${groupId}:${artifactId}@${version}`) + } + + return null + } +} diff --git a/packages/cli/test/resolvers/index.test.ts b/packages/cli/test/resolvers/index.test.ts index 61e4c7f..981b315 100644 --- a/packages/cli/test/resolvers/index.test.ts +++ b/packages/cli/test/resolvers/index.test.ts @@ -2,6 +2,12 @@ import { describe, expect, it } from 'bun:test' import { getResolver } from '../../src/resolvers/index.js' describe('getResolver', () => { + it('returns a resolver for maven', () => { + const resolver = getResolver('maven') + expect(resolver).toBeDefined() + expect(typeof resolver.resolve).toBe('function') + }) + it('returns a resolver for npm', () => { const resolver = getResolver('npm') expect(resolver).toBeDefined() diff --git a/packages/cli/test/resolvers/maven.test.ts b/packages/cli/test/resolvers/maven.test.ts new file mode 100644 index 0000000..752d2b3 --- /dev/null +++ b/packages/cli/test/resolvers/maven.test.ts @@ -0,0 +1,285 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { MavenResolver } from '../../src/resolvers/maven.js' + +describe('MavenResolver', () => { + const resolver = new MavenResolver() + const originalFetch = globalThis.fetch + + beforeEach(() => { + globalThis.fetch = mock() as typeof fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function mockResponse(body: unknown, status = 200) { + const content = typeof body === 'string' ? body : JSON.stringify(body) + return new Response(content, { status }) + } + + const SEARCH_GUAVA = { + response: { + numFound: 1, + docs: [{ g: 'com.google.guava', a: 'guava', v: '33.4.0-jre' }], + }, + } + + const SEARCH_GAV_WITH_SCM = { + response: { + numFound: 1, + docs: [{ 'g': 'com.google.guava', 'a': 'guava', 'v': '33.4.0-jre', 'scm.url': 'https://github.com/google/guava' }], + }, + } + + const SEARCH_GAV_NO_SCM = { + response: { + numFound: 1, + docs: [{ g: 'com.google.guava', a: 'guava', v: '33.4.0-jre' }], + }, + } + + const POM_WITH_SCM = ` + https://guava.dev + + https://github.com/google/guava + + ` + + const POM_WITH_URL_ONLY = ` + https://github.com/google/guava + ` + + const METADATA_XML = ` + + com.google.guava + guava + + 33.4.0-jre + 33.4.0-jre + + ` + + /** + * Mock fetch for the standard resolve flow: + * (1) Search API for version, (2) extended GAV for scmUrl, (3) POM for repo URL + */ + function mockStandardFlow(opts: { + searchResponse?: unknown + searchStatus?: number + gavResponse?: unknown + gavStatus?: number + pomResponse?: string + pomStatus?: number + }) { + const f = globalThis.fetch as ReturnType + // (1) Search API + f.mockResolvedValueOnce(mockResponse(opts.searchResponse ?? SEARCH_GUAVA, opts.searchStatus)) + // (2) Extended GAV search + f.mockResolvedValueOnce(mockResponse(opts.gavResponse ?? SEARCH_GAV_NO_SCM, opts.gavStatus)) + // (3) POM XML + if (opts.pomResponse !== undefined || opts.pomStatus !== undefined) { + f.mockResolvedValueOnce(mockResponse(opts.pomResponse ?? POM_WITH_SCM, opts.pomStatus)) + } + } + + describe('Search API scmUrl priority (FR-5)', () => { + it('uses scmUrl from Search API when available, skips POM', async () => { + const f = globalThis.fetch as ReturnType + // (1) Search API + f.mockResolvedValueOnce(mockResponse(SEARCH_GUAVA)) + // (2) Extended GAV with scmUrl + f.mockResolvedValueOnce(mockResponse(SEARCH_GAV_WITH_SCM)) + // POM should NOT be fetched + + const result = await resolver.resolve('com.google.guava:guava', 'latest') + expect(result.repo).toBe('google/guava') + expect(result.resolvedVersion).toBe('33.4.0-jre') + // Only 2 fetch calls (no POM) + expect(f).toHaveBeenCalledTimes(2) + }) + + it('uses scmUrl from Search API for explicit version, skips POM', async () => { + const f = globalThis.fetch as ReturnType + f.mockResolvedValueOnce(mockResponse(SEARCH_GUAVA)) + f.mockResolvedValueOnce(mockResponse(SEARCH_GAV_WITH_SCM)) + + const result = await resolver.resolve('com.google.guava:guava', '33.4.0-jre') + expect(result.repo).toBe('google/guava') + expect(f).toHaveBeenCalledTimes(2) + }) + + it('falls back to POM when scmUrl is absent', async () => { + mockStandardFlow({ pomResponse: POM_WITH_SCM }) + + const result = await resolver.resolve('com.google.guava:guava', 'latest') + expect(result.repo).toBe('google/guava') + }) + }) + + describe('resolve with Search API + POM', () => { + it('resolves latest version', async () => { + mockStandardFlow({ pomResponse: POM_WITH_SCM }) + + const result = await resolver.resolve('com.google.guava:guava', 'latest') + expect(result).toEqual({ + repo: 'google/guava', + ref: 'v33.4.0-jre', + fallbackRefs: ['33.4.0-jre'], + resolvedVersion: '33.4.0-jre', + }) + }) + + it('resolves explicit version', async () => { + mockStandardFlow({ pomResponse: POM_WITH_SCM }) + + const result = await resolver.resolve('com.google.guava:guava', '33.4.0-jre') + expect(result).toEqual({ + repo: 'google/guava', + ref: 'v33.4.0-jre', + fallbackRefs: ['33.4.0-jre'], + resolvedVersion: '33.4.0-jre', + }) + }) + + it('falls back to when has no GitHub URL', async () => { + mockStandardFlow({ pomResponse: POM_WITH_URL_ONLY }) + + const result = await resolver.resolve('com.google.guava:guava', 'latest') + expect(result.repo).toBe('google/guava') + }) + + it('falls back to when is non-GitHub', async () => { + const pomWithNonGithubScm = ` + https://github.com/google/guava + + https://gitbox.apache.org/repos/asf?p=guava.git + + ` + mockStandardFlow({ pomResponse: pomWithNonGithubScm }) + + const result = await resolver.resolve('com.google.guava:guava', 'latest') + expect(result.repo).toBe('google/guava') + }) + + it('resolves when POM has git+https scm URL', async () => { + const pomWithGitScm = ` + + git+https://github.com/google/guava.git + + ` + mockStandardFlow({ pomResponse: pomWithGitScm }) + + const result = await resolver.resolve('com.google.guava:guava', 'latest') + expect(result.repo).toBe('google/guava') + }) + }) + + describe('Search API unavailable fallback (FR-4, AC-3)', () => { + it('falls back to maven-metadata.xml + POM when Search API is down for latest', async () => { + const f = globalThis.fetch as ReturnType + // (1) Search API fails + f.mockResolvedValueOnce(mockResponse({}, 503)) + // (2) maven-metadata.xml + f.mockResolvedValueOnce(mockResponse(METADATA_XML)) + // (3) POM XML + f.mockResolvedValueOnce(mockResponse(POM_WITH_SCM)) + + const result = await resolver.resolve('com.google.guava:guava', 'latest') + expect(result.repo).toBe('google/guava') + expect(result.resolvedVersion).toBe('33.4.0-jre') + }) + + it('uses explicit version directly when Search API is down', async () => { + const f = globalThis.fetch as ReturnType + // (1) Search API fails for explicit version + f.mockResolvedValueOnce(mockResponse({}, 503)) + // (2) POM XML (skips extended search since Search API failed) + f.mockResolvedValueOnce(mockResponse(POM_WITH_SCM)) + + const result = await resolver.resolve('com.google.guava:guava', '33.4.0-jre') + expect(result.repo).toBe('google/guava') + expect(result.resolvedVersion).toBe('33.4.0-jre') + }) + + it('resolves from tag when is absent', async () => { + const metadataLatestOnly = ` + + + 33.4.0-jre + + ` + + const f = globalThis.fetch as ReturnType + f.mockResolvedValueOnce(mockResponse({}, 503)) + f.mockResolvedValueOnce(mockResponse(metadataLatestOnly)) + f.mockResolvedValueOnce(mockResponse(POM_WITH_SCM)) + + const result = await resolver.resolve('com.example:artifact', 'latest') + expect(result.resolvedVersion).toBe('33.4.0-jre') + }) + + it('throws when metadata has no release or latest tag', async () => { + const malformedMetadata = `` + + const f = globalThis.fetch as ReturnType + f.mockResolvedValueOnce(mockResponse({}, 503)) + f.mockResolvedValueOnce(mockResponse(malformedMetadata)) + + await expect( + resolver.resolve('com.example:artifact', 'latest'), + ).rejects.toThrow('no or tag') + }) + }) + + describe('error handling', () => { + it('throws on invalid name format (missing colon)', async () => { + await expect( + resolver.resolve('guava', 'latest'), + ).rejects.toThrow('groupId:artifactId') + }) + + it('throws on empty groupId', async () => { + await expect( + resolver.resolve(':guava', 'latest'), + ).rejects.toThrow('groupId:artifactId') + }) + + it('throws on empty artifactId (trailing colon)', async () => { + await expect( + resolver.resolve('com.google.guava:', 'latest'), + ).rejects.toThrow('groupId:artifactId') + }) + + it('throws when Search API returns 0 results and metadata also fails', async () => { + const f = globalThis.fetch as ReturnType + // (1) Search API returns 0 results → throws → tries metadata fallback + f.mockResolvedValueOnce(mockResponse({ response: { numFound: 0, docs: [] } })) + // (2) maven-metadata.xml also fails + f.mockResolvedValueOnce(mockResponse('Not Found', 404)) + + await expect( + resolver.resolve('com.example:nonexistent', 'latest'), + ).rejects.toThrow('maven-metadata.xml returned 404') + }) + + it('throws when POM has no GitHub URL', async () => { + const pomNoGithub = ` + https://example.com + ` + mockStandardFlow({ pomResponse: pomNoGithub }) + + await expect( + resolver.resolve('com.google.guava:guava', 'latest'), + ).rejects.toThrow('Cannot resolve GitHub repository') + }) + + it('throws when POM fetch fails and no repo found', async () => { + mockStandardFlow({ pomResponse: 'Not Found', pomStatus: 404 }) + + await expect( + resolver.resolve('com.google.guava:guava', 'latest'), + ).rejects.toThrow('Cannot resolve GitHub repository') + }) + }) +})