From 6a275d7d6af490a08e0fc831ae388c662ae2f25a Mon Sep 17 00:00:00 2001 From: IlyasRidhuan Date: Fri, 16 Oct 2026 09:25:24 +0000 Subject: [PATCH] chore: bump to v6 rc --- README.md | 58 ++++++++---- src/index.ts | 21 +++-- src/repos/config.ts | 37 +++++++- src/tools/search.ts | 16 +++- src/tools/sync-split.ts | 78 ++++++++++++++++ src/tools/sync.ts | 43 +++++++-- src/utils/error-lookup.ts | 21 +++-- src/utils/format.ts | 6 +- src/utils/git.ts | 20 ++-- src/utils/release.ts | 49 ++++++++++ src/utils/search.ts | 26 +++++- src/utils/sources.ts | 55 +++++++++++ src/utils/sync-metadata.ts | 49 ++++++++-- src/utils/version-check.ts | 52 +++-------- test.mjs | 99 +++++++++----------- tests/repos/config.test.ts | 133 ++++++++------------------- tests/tools/search.test.ts | 5 +- tests/tools/sync-split.test.ts | 147 ++++++++++++++++++++++++++++++ tests/tools/sync.test.ts | 44 +++++++++ tests/utils/git.test.ts | 26 ++++-- tests/utils/release.test.ts | 56 ++++++++++++ tests/utils/search.test.ts | 2 + tests/utils/split-search.test.ts | 103 +++++++++++++++++++++ tests/utils/sync-metadata.test.ts | 36 +++++++- tests/utils/version-check.test.ts | 42 +++++++-- 25 files changed, 947 insertions(+), 277 deletions(-) create mode 100644 src/tools/sync-split.ts create mode 100644 src/utils/release.ts create mode 100644 src/utils/sources.ts create mode 100644 tests/tools/sync-split.test.ts create mode 100644 tests/utils/release.test.ts create mode 100644 tests/utils/split-search.test.ts diff --git a/README.md b/README.md index 3661d4f..496622e 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ An MCP (Model Context Protocol) server that provides local access to Aztec docum ## Features -- **Version Support**: Clone specific Aztec release tags (e.g., `v4.3.0`) -- **Local Repository Cloning**: Automatically clones Aztec repositories with sparse checkout for efficiency +- **Version Support**: Clone specific Aztec release tags (e.g., `v6.0.0-rc.6`) +- **Lightweight v6 Sync**: Clones only selected paths from `aztec-node`, pinned to the `labs` commit recorded in the requested `aztec-packages` release. Protocol and Noir sources are optional. - **Fast Code Search**: Search Noir contracts and TypeScript files using ripgrep (with fallback) - **Documentation Search**: Search Aztec documentation locally; with an API key, semantic vector search across the full corpora (framework docs, examples, Noir stdlib, TypeScript SDK, protocol circuits) - **Error Lookup**: Static catalog (Solidity / circuit / TX / AVM errors) plus optional semantic fallback for unrecognized errors when an API key is configured - **Example Discovery**: List and read Aztec contract examples -- **Version-sync Gate**: When using the hosted semantic backend, the server detects mismatches between your local clone tag and the indexed corpus and refuses to query across versions unless explicitly overridden +- **Version-sync Gate**: When using the hosted semantic backend, the server detects mismatches between your local release and the indexed corpus and refuses to query across versions unless explicitly overridden ## API Key (optional, recommended) @@ -73,7 +73,7 @@ Add to your `.mcp.json`. The minimal config is just the command; add `env.API_KE | `API_KEY` | unset | Personal API key from `/mcp-key` in the Noir Discord (). Unset → local-only mode. | | `API_URL` | `https://aztec.adjacentpossible.dev` | DocsGPT backend the semantic search hits. Override to point at a self-hosted instance. | | `REQUEST_TIMEOUT` | `60000` | Semantic-search request timeout (ms). | -| `AZTEC_DEFAULT_VERSION` | `v4.3.0` | Default version tag for `aztec_sync_repos`. | +| `AZTEC_DEFAULT_VERSION` | `v6.0.0-rc.6` | Default version tag for `aztec_sync_repos`. | | `AZTEC_MCP_REPOS_DIR` | `~/.aztec-mcp/repos/` | Where local clones live. | ## Available Tools @@ -83,24 +83,40 @@ Add to your `.mcp.json`. The minimal config is just the command; add `env.API_KE Clone or update Aztec repositories locally. **Run this first** to enable other tools. ``` -Clones: -- aztec-packages (docs, aztec-nr, noir-contracts) - sparse checkout -- aztec-examples (full) -- aztec-starter (full) +Default for v6: +- aztec-node: SDK, Aztec.nr, contract examples, playground, and documentation + +Optional: +- aztec-packages: protocol, proving-system TypeScript, and L1 sources +- noir: language docs, standard library, and tooling ``` **Parameters:** -- `version` (string): Aztec version tag to clone (e.g., `v4.3.0`). Defaults to latest supported version. +- `version` (string): Aztec version tag to clone (e.g., `v6.0.0-rc.6`). Defaults to latest supported version. - `force` (boolean): Force re-clone even if repos exist -- `repos` (string[]): Specific repos to sync +- `repos` (string[]): For v6, select optional `aztec-packages` and/or `noir` sources. `aztec-node` is always included to keep SDK and documentation aligned with the release. Older versions retain their legacy repository configuration. **Example - Clone specific version:** ``` -aztec_sync_repos({ version: "v4.3.0" }) +aztec_sync_repos({ version: "v6.0.0-rc.6" }) +``` + +The default sync uses a temporary, shallow, blob-filtered Git fetch to read the release's `labs` and Noir gitlinks. It then removes that temporary Git data and makes one persistent shallow, partial, sparse checkout of `https://github.com/aztec-labs-eng/aztec-node` at the pinned commit. + +To include protocol/L1 and Noir sources: + +``` +aztec_sync_repos({ version: "v6.0.0-rc.6", repos: ["aztec-packages", "noir"] }) ``` +The `v6.0.0-rc.6` release pins `aztec-node` at `1b6de149f4fe1cde3c5fb876d3822289f490cd2a`. That checkout has no exact `rc.6` documentation snapshot, so local documentation search uses its unversioned docs and reports the commit as provenance. It does not substitute an older snapshot. + +Sync metadata records the release and active checkout commits. Searches exclude leftover repositories from previous releases. Optional sources remain active on a same-version resync if their commits still match; when changing versions, specify the optional sources again. Interrupted v6 syncs block version-sensitive searches until sync succeeds, and retries preserve the requested optional repositories. Downgrading to a legacy release requires a full sync (omit `repos`) to avoid mixing releases. Existing installs should run `aztec_sync_repos` with the version above to migrate. + +The companion repositories `aztec-examples`, `aztec-starter`, and `demo-wallet` are not offered for v6 because matching release tags are unavailable. Contract examples come from the pinned `aztec-node` checkout. + ### `aztec_status` Check the status of cloned repositories. @@ -133,7 +149,7 @@ Search Aztec documentation. Local ripgrep by default; semantic vector search whe - `maxResults` (number): Maximum results (default: 20 local; 5 semantic, max 20) - `chunks` (number, semantic only): Number of result chunks (1-20). If omitted, `maxResults` is used. - `useLocalFallback` (boolean, semantic only): If the semantic backend fails, fall back to local ripgrep. Default `false` so backend errors surface clearly. -- `allowVersionMismatch` (boolean, semantic only): Override the version-sync gate. Default `false`. The gate refuses to search when your local `aztec-packages` clone tag differs from the corpus version the backend has indexed. +- `allowVersionMismatch` (boolean, semantic only): Override the version-sync gate. Default `false`. The gate refuses to search when your local release version differs from the corpus version the backend has indexed, including release-candidate suffixes (`rc.1` and `rc.6` are distinct). ### `aztec_list_examples` @@ -203,7 +219,7 @@ Set the default Aztec version with the `AZTEC_DEFAULT_VERSION` environment varia "command": "npx", "args": ["-y", "@aztec/mcp-server@latest"], "env": { - "AZTEC_DEFAULT_VERSION": "v3.0.0-devnet.6-plugin.1" + "AZTEC_DEFAULT_VERSION": "v6.0.0-rc.6" } } } @@ -223,6 +239,10 @@ npm install # Build npm run build +npm test + +node test.mjs + # Run locally node dist/index.js ``` @@ -235,11 +255,13 @@ node dist/index.js ## Cloned Repositories -| Repository | Description | Checkout | -| ----------------------------------------------------------------- | ----------------- | -------------------------------------- | -| [aztec-packages](https://github.com/AztecProtocol/aztec-packages) | Main monorepo | Sparse: docs, aztec-nr, noir-contracts | -| [aztec-examples](https://github.com/AztecProtocol/aztec-examples) | Official examples | Full | -| [aztec-starter](https://github.com/AztecProtocol/aztec-starter) | Starter template | Full | +| Repository | v6 use | Checkout | +| --- | --- | --- | +| [aztec-node](https://github.com/aztec-labs-eng/aztec-node) | Default: SDK, Aztec.nr, contracts, docs | Shallow, partial, sparse; pinned by the release's `labs` gitlink | +| [aztec-packages](https://github.com/AztecProtocol/aztec-packages) | Optional: protocol and L1 sources | Shallow, partial, sparse; pinned to the release commit | +| [noir](https://github.com/noir-lang/noir) | Optional: language documentation and tooling | Shallow, partial, sparse; pinned by the release's Noir gitlink | + +Versions before v6 retain the existing monorepo and companion repository configuration. ## License diff --git a/src/index.ts b/src/index.ts index 6fbb473..f732249 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,15 +90,15 @@ const SEMANTIC_INSTRUCTIONS = "natural-language documentation queries; `aztec_search_code` for " + "regex-based code search; `aztec_lookup_error` for error diagnosis. " + "The server enforces a version-sync gate between the user's local " + - "aztec-packages clone tag and the corpus version it queries — if the " + + "release version and the corpus version it queries — if the " + "user gets a `version-mismatch` result, they can either run " + "`aztec_sync_repos` with the corpus version, or pass " + "`allowVersionMismatch: true` to query across versions intentionally."; const LOCAL_ONLY_INSTRUCTIONS = "This Aztec MCP server is running in LOCAL-ONLY mode (no API_KEY " + - "configured). It can answer questions from cloned aztec-packages, " + - "aztec-examples, and aztec-starter repositories using ripgrep search " + + "configured). It can answer questions from the pinned aztec-node checkout " + + "and optional protocol or Noir sources using ripgrep search " + "and a static error catalog. It does NOT have semantic search across " + "the full Aztec corpora (Aztec.nr framework, Noir stdlib, contract " + "examples, TypeScript SDK, protocol circuits, etc.) and does NOT have " + @@ -203,7 +203,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: "boolean", description: "Override the version-sync gate. By default the search refuses to run when the local " + - "aztec-packages clone tag differs from the corpus version the DocsGPT backend has indexed. " + + "release version differs from the corpus version the DocsGPT backend has indexed. " + "Set true to query anyway (results reflect the corpus version, not your local clone).", }, } @@ -217,7 +217,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { name: "aztec_sync_repos", description: "Clone or update Aztec repositories locally. Run this first to enable searching. " + - "Clones: aztec-packages (docs, aztec-nr, contracts), aztec-examples, aztec-starter. " + + "For v6, sparsely clones aztec-node at the release-pinned labs commit. " + + "Protocol sources (aztec-packages) and Noir are optional. " + "Specify a version to clone a specific Aztec release tag.", inputSchema: { type: "object" as const, @@ -225,7 +226,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { version: { type: "string", description: - "Aztec version tag to clone (e.g., 'v4.3.0'). Defaults to latest supported version.", + "Aztec version tag to clone (e.g., 'v6.0.0-rc.6'). Defaults to latest supported version.", }, force: { type: "boolean", @@ -235,7 +236,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: "array", items: { type: "string" }, description: - "Specific repos to sync. Options: aztec-packages, aztec-examples, aztec-starter", + "For v6: aztec-node (always included), aztec-packages (optional protocol/L1 sources), noir (optional language sources). Older releases use the legacy repository set.", }, }, }, @@ -271,7 +272,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { repo: { type: "string", description: - "Specific repo to search. Options: aztec-packages, aztec-examples, aztec-starter", + "Specific active repo to search. For v6: aztec-node, plus aztec-packages or noir if enabled during sync.", }, maxResults: { type: "number", @@ -323,7 +324,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { path: { type: "string", description: - "File path relative to repos directory (e.g., 'aztec-packages/docs/docs/tutorials/...')", + "File path relative to repos directory (e.g., 'aztec-node/noir-projects/noir-contracts/contracts/app/token_contract/src/main.nr')", }, }, required: ["path"], @@ -492,7 +493,7 @@ function ensureAutoResync(): void { log(`Auto-resync partial: ${syncResult.message}`, "info"); } else { log( - `Auto-resync failed: ${syncResult.message}. Local tools will use existing checkouts.`, + `Auto-resync failed: ${syncResult.message}`, "warning" ); } diff --git a/src/repos/config.ts b/src/repos/config.ts index 37cabaf..c88fb10 100644 --- a/src/repos/config.ts +++ b/src/repos/config.ts @@ -7,6 +7,8 @@ export interface RepoConfig { name: string; /** Git URL to clone from */ url: string; + /** Excluded from the default sync; available through repos. */ + optional?: boolean; /** Branch to checkout (defaults to main/master) */ branch?: string; /** Tag to checkout (overrides branch if specified) */ @@ -31,7 +33,7 @@ export interface RepoConfig { } /** Default Aztec version (tag) to use - can be overridden via AZTEC_DEFAULT_VERSION env var */ -export const DEFAULT_AZTEC_VERSION = process.env.AZTEC_DEFAULT_VERSION || "v4.3.0"; +export const DEFAULT_AZTEC_VERSION = process.env.AZTEC_DEFAULT_VERSION || "v6.0.0-rc.6"; /** * Base Aztec repository configurations (without version) @@ -134,11 +136,42 @@ const BASE_REPOS: Omit[] = [ /** * Get Aztec repositories configured for a specific version - * @param version - The Aztec version tag (e.g., "v4.3.0") + * @param version - The Aztec version tag (e.g., "v6.0.0-rc.6") */ export function getAztecRepos(version?: string): RepoConfig[] { const tag = version || DEFAULT_AZTEC_VERSION; + if (Number(tag.replace(/^v/, "").split(".")[0]) >= 6) { + return [ + { + name: "aztec-node", + url: "https://github.com/aztec-labs-eng/aztec-node", + description: "Aztec SDK, Aztec.nr, contracts, and docs pinned by the release's labs gitlink", + sparse: [ + "noir-projects/aztec-nr", + "noir-projects/noir-contracts", + "yarn-project", + "playground", + "docs/docs-developers/docs", + "docs/docs-operate", + "docs/docs-participate", + `docs/developer_versioned_docs/version-${tag.startsWith("v") ? tag : `v${tag}`}`, + ], + searchPatterns: { code: ["*.nr", "*.ts"], docs: ["*.md", "*.mdx"] }, + }, + { + name: "aztec-packages", + url: "https://github.com/AztecProtocol/aztec-packages", + tag, + optional: true, + description: "Optional Aztec protocol, proving system, and L1 sources", + sparse: ["noir-projects/fnd", "protocol", "barretenberg/ts/bb.js/src", "l1-contracts/src"], + searchPatterns: { code: ["*.nr", "*.ts", "*.sol"], docs: ["*.md", "*.mdx"] }, + }, + { ...BASE_REPOS.find((repo) => repo.name === "noir")!, optional: true }, + ]; + } + return BASE_REPOS.map((repo) => ({ ...repo, // Only apply version tag to Aztec repos that don't opt out diff --git a/src/tools/search.ts b/src/tools/search.ts index dba7343..f2c08a0 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -12,6 +12,7 @@ import { FileInfo, } from "../utils/search.js"; import { isRepoCloned } from "../utils/git.js"; +import { getActiveRepoNames, getDocsSource } from "../utils/sources.js"; import { getRepoNames } from "../repos/config.js"; import { DocsGPTClient, DocsGPTClientError } from "../backends/docsgpt-client.js"; import type { SemanticSearchResult } from "../backends/docsgpt-client.js"; @@ -32,6 +33,10 @@ export function searchAztecCode(options: { } { const { query, filePattern = "*.nr", repo, maxResults = 30 } = options; + const active = getActiveRepoNames(); + if (repo && active && !active.includes(repo)) { + return { success: false, results: [], message: `Repository '${repo}' is not active for this release. Run aztec_sync_repos with repos to enable optional sources.` }; + } // Check if repos are cloned if (repo && !isRepoCloned(repo)) { return { @@ -41,7 +46,7 @@ export function searchAztecCode(options: { }; } - const anyCloned = getRepoNames().some(isRepoCloned); + const anyCloned = (getActiveRepoNames() ?? getRepoNames()).some(isRepoCloned); if (!anyCloned) { return { success: false, @@ -193,8 +198,9 @@ function ripgrepFallback( ): DocsSearchResult { const { query, section, maxResults = 20 } = options; - if (!isRepoCloned("aztec-packages-docs")) { - const fallbackError = "aztec-packages-docs is not cloned. Run aztec_sync_repos first to get documentation."; + const source = getDocsSource(); + if (!isRepoCloned(source.repo)) { + const fallbackError = `${source.repo} is not cloned. Run aztec_sync_repos first to get documentation.`; if (semanticError !== undefined) { return { kind: "error", @@ -225,7 +231,7 @@ function ripgrepFallback( : "") + (results.length > 0 ? `Found ${results.length} documentation matches` - : "No documentation matches found"), + : "No documentation matches found") + (source.notice ? `\n${source.notice}` : ""), }, }; } @@ -240,7 +246,7 @@ export function listAztecExamples(options: { category?: string }): { } { const { category } = options; - const anyCloned = getRepoNames().some(isRepoCloned); + const anyCloned = (getActiveRepoNames() ?? getRepoNames()).some(isRepoCloned); if (!anyCloned) { return { success: false, diff --git a/src/tools/sync-split.ts b/src/tools/sync-split.ts new file mode 100644 index 0000000..abd9acd --- /dev/null +++ b/src/tools/sync-split.ts @@ -0,0 +1,78 @@ +import type { RepoConfig } from "../repos/config.js"; +import { cloneRepo, ensureReposDir, getRepoCommit, type Logger } from "../utils/git.js"; +import { getNodeDocsSource } from "../utils/sources.js"; +import { resolveReleaseCommits } from "../utils/release.js"; +import { beginVersionSync, readSyncMetadata, writeSyncMetadata } from "../utils/sync-metadata.js"; +import { clearErrorCache } from "../utils/error-lookup.js"; +import type { SyncResult } from "./sync.js"; + +/** v6 defaults to a single checkout; optional sources join the same release. */ +export async function syncSplitRepos( + options: { version: string; repos?: string[]; force?: boolean; log?: Logger }, + configs: RepoConfig[], +): Promise { + const { version, force = false, log } = options; + const previous = readSyncMetadata(); + const resuming = previous?.pendingVersion?.replace(/^v/, "") === version.replace(/^v/, ""); + const requested = options.repos ?? (resuming ? previous?.pendingRepositories : undefined); + const result: SyncResult = { success: false, metadataSafe: false, version, message: "", repos: [] }; + const unknown = requested?.filter((name) => !configs.some((config) => config.name === name)); + if (requested?.length === 0 || unknown?.length) { + result.message = `Unsupported repositories for ${version}: ${unknown?.join(", ") || "empty selection"}. Available: ${configs.map((r) => r.name).join(", ")}`; + return result; + } + + const sameVersion = previous?.aztecVersion?.replace(/^v/, "") === version.replace(/^v/, ""); + const selected = configs.filter((config) => !config.optional || requested?.includes(config.name)); + let commits; + try { + commits = await resolveReleaseCommits(version, log); + } catch (error) { + result.message = `Could not resolve ${version}: ${error instanceof Error ? error.message : String(error)}`; + return result; + } + + const expectedCommits: Record = { + "aztec-node": commits.nodeCommit, "aztec-packages": commits.packagesCommit, noir: commits.noirCommit, + }; + const repositories: Record = {}; + if (sameVersion && previous?.repositories) { + for (const [name, commit] of Object.entries(previous.repositories)) { + if (expectedCommits[name] === commit && await getRepoCommit(name, true) === commit) { + repositories[name] = commit; + } + } + } + try { + ensureReposDir(); + beginVersionSync(version, selected.map((config) => config.name)); + } catch (error) { + result.message = `Cannot persist sync intent: ${error instanceof Error ? error.message : String(error)}`; + return result; + } + for (const config of selected) { + const commit = expectedCommits[config.name]; + try { + const status = await cloneRepo({ ...config, tag: undefined, branch: undefined, commit }, force, log); + repositories[config.name] = commit; + result.repos.push({ name: config.name, status, commit }); + } catch (error) { + result.repos.push({ name: config.name, status: `Error: ${error instanceof Error ? error.message : String(error)}` }); + result.message = `Sync incomplete: ${config.name} failed; run aztec_sync_repos again`; + clearErrorCache(); + return result; + } + } + + const docsNotice = getNodeDocsSource(version, commits.nodeCommit).notice; + result.success = true; + try { + writeSyncMetadata(version, repositories); + result.metadataSafe = true; + result.message = `Synced ${result.repos.length} ${result.repos.length === 1 ? "repository" : "repositories"} for ${version}. ${docsNotice}`; + } catch { + result.message = "Repositories synced but failed to persist release metadata; run aztec_sync_repos again before searching."; + } + clearErrorCache(); + return result; +} diff --git a/src/tools/sync.ts b/src/tools/sync.ts index db49213..5553774 100644 --- a/src/tools/sync.ts +++ b/src/tools/sync.ts @@ -5,8 +5,9 @@ import { existsSync } from "fs"; import { join } from "path"; import { AZTEC_REPOS, getAztecRepos, DEFAULT_AZTEC_VERSION, RepoConfig } from "../repos/config.js"; -import { cloneRepo, getReposStatus, getNoirCommitFromAztec, getRepoPath, REPOS_DIR, Logger } from "../utils/git.js"; -import { writeSyncMetadata, stampMetadataMcpVersion, readSyncMetadata, SyncMetadata } from "../utils/sync-metadata.js"; +import { ensureReposDir, cloneRepo, getReposStatus, getNoirCommitFromAztec, getRepoPath, REPOS_DIR, Logger } from "../utils/git.js"; +import { beginVersionSync, writeSyncMetadata, stampMetadataMcpVersion, readSyncMetadata, SyncMetadata } from "../utils/sync-metadata.js"; +import { syncSplitRepos } from "./sync-split.js"; import { clearErrorCache } from "../utils/error-lookup.js"; export interface RepoSyncStatus { @@ -27,6 +28,21 @@ export interface SyncResult { repos: RepoSyncStatus[]; } +function prepareLegacyMigration(version: string, repositories: RepoConfig[], isFullSync: boolean): string | null { + const previous = readSyncMetadata(); + if (!previous?.repositories && !previous?.pendingVersion) return null; + if (!isFullSync) { + return "Changing from a split release to a legacy release requires a full sync. Omit repos and retry."; + } + try { + ensureReposDir(); + beginVersionSync(version, repositories.map((repo) => repo.name)); + return null; + } catch (error) { + return `Cannot persist sync intent: ${error instanceof Error ? error.message : String(error)}`; + } +} + /** * Sync all repositories (clone if missing, update if exists) * Syncs aztec-packages first to determine the correct Noir version @@ -43,6 +59,10 @@ export async function syncRepos(options: { const configuredRepos = version ? getAztecRepos(version) : AZTEC_REPOS; const effectiveVersion = version || DEFAULT_AZTEC_VERSION; + if (configuredRepos.some((repo) => repo.name === "aztec-node")) { + return syncSplitRepos({ ...options, version: effectiveVersion }, configuredRepos); + } + // Filter repos if specific ones requested let reposToSync = repoNames ? configuredRepos.filter((r) => repoNames.includes(r.name)) @@ -58,6 +78,15 @@ export async function syncRepos(options: { }; } + const isFullSync = !repoNames || configuredRepos.every((repo) => repoNames.includes(repo.name)); + const migrationError = prepareLegacyMigration(effectiveVersion, reposToSync, isFullSync); + if (migrationError) { + return { + success: false, metadataSafe: false, version: effectiveVersion, repos: [], + message: migrationError, + }; + } + // Generate synthetic repo configs from sparsePathOverrides const syntheticRepos: RepoConfig[] = []; for (const repo of reposToSync) { @@ -188,7 +217,6 @@ export async function syncRepos(options: { // Metadata is safe to write when all repos succeeded and every configured // repo was included (explicit full list counts as full sync). Docs-missing // is cosmetic — repos are usable and auto-resync should not keep retrying. - const isFullSync = !repoNames || configuredRepos.every((r) => repoNames.includes(r.name)); let metadataSafe = allSuccess && isFullSync; let metadataWriteFailed = false; @@ -243,9 +271,12 @@ export async function getStatus(): Promise<{ }[]; syncMetadata: SyncMetadata | null; }> { - const statusMap = await getReposStatus(AZTEC_REPOS); + const metadata = readSyncMetadata(); + const version = metadata?.pendingVersion || metadata?.aztecVersion; + const configs = version ? getAztecRepos(version) : AZTEC_REPOS; + const statusMap = await getReposStatus(configs); - const repos = AZTEC_REPOS.map((config) => { + const repos = configs.map((config) => { const status = statusMap.get(config.name); return { name: config.name, @@ -258,6 +289,6 @@ export async function getStatus(): Promise<{ return { reposDir: REPOS_DIR, repos, - syncMetadata: readSyncMetadata(), + syncMetadata: metadata, }; } diff --git a/src/utils/error-lookup.ts b/src/utils/error-lookup.ts index 54f7ce5..d99094e 100644 --- a/src/utils/error-lookup.ts +++ b/src/utils/error-lookup.ts @@ -8,6 +8,7 @@ import { readFileSync, existsSync } from "fs"; import { join } from "path"; import { createHash } from "crypto"; +import { getActiveRepoNames, getCodeRepo } from "./sources.js"; import { REPOS_DIR } from "./git.js"; import { searchCode } from "./search.js"; import type { SearchResult } from "./search.js"; @@ -314,18 +315,20 @@ export function parseOperatorFaq(filePath: string): ErrorEntry[] { // --------------------------------------------------------------------------- function getDynamicEntries(): ErrorEntry[] { + const active = getActiveRepoNames(); + const codeRepo = getCodeRepo(); if (cachedDynamic) return cachedDynamic; const entries: ErrorEntry[] = []; // Solidity errors const errorsPath = join(REPOS_DIR, "aztec-packages", "l1-contracts", "src", "core", "libraries", "Errors.sol"); - entries.push(...parseSolidityErrors(errorsPath)); + if (!active || active.includes("aztec-packages")) entries.push(...parseSolidityErrors(errorsPath)); // TX validation errors — search a few known locations const txErrorPaths = [ - join(REPOS_DIR, "aztec-packages", "yarn-project", "stdlib", "src", "tx", "validator", "error_texts.ts"), - join(REPOS_DIR, "aztec-packages", "yarn-project", "circuit-types", "src", "tx", "validator", "error_texts.ts"), + join(REPOS_DIR, codeRepo, "yarn-project", "stdlib", "src", "tx", "validator", "error_texts.ts"), + join(REPOS_DIR, codeRepo, "yarn-project", "circuit-types", "src", "tx", "validator", "error_texts.ts"), ]; for (const p of txErrorPaths) { const parsed = parseTxValidationErrors(p); @@ -335,8 +338,9 @@ function getDynamicEntries(): ErrorEntry[] { } } - // Debugging doc (lives in aztec-packages-docs, cloned from `next` branch) - const debugPaths = [ + const debugPaths = codeRepo === "aztec-node" ? [ + join(REPOS_DIR, codeRepo, "docs", "docs-developers", "docs", "aztec-nr", "debugging.md"), + ] : [ join(REPOS_DIR, "aztec-packages-docs", "docs", "docs-developers", "docs", "aztec-nr", "debugging.md"), join(REPOS_DIR, "aztec-packages", "docs", "docs-developers", "docs", "aztec-nr", "debugging.md"), ]; @@ -348,8 +352,9 @@ function getDynamicEntries(): ErrorEntry[] { } } - // Operator FAQ (lives in aztec-packages-docs, cloned from `next` branch) - const faqPaths = [ + const faqPaths = codeRepo === "aztec-node" ? [ + join(REPOS_DIR, codeRepo, "docs", "docs-operate", "operators", "operator-faq.md"), + ] : [ join(REPOS_DIR, "aztec-packages-docs", "docs", "docs-operate", "operators", "operator-faq.md"), join(REPOS_DIR, "aztec-packages", "docs", "docs-operate", "operators", "operator-faq.md"), ]; @@ -522,7 +527,7 @@ export function lookupError( try { const raw = searchCode(query, { filePattern: "*.ts", - repo: "aztec-packages", + repo: getCodeRepo(), maxResults: RAW_CODE_LIMIT, }); codeMatches = raw.filter(isUsefulCodeRef).slice(0, KEEP_CODE_LIMIT); diff --git a/src/utils/format.ts b/src/utils/format.ts index 2bff566..0d39971 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -73,6 +73,9 @@ export function formatStatus(status: { ); } lines.push(`Aztec version: ${status.syncMetadata.aztecVersion}`); + if (status.syncMetadata.pendingVersion) { + lines.push(`Sync incomplete for ${status.syncMetadata.pendingVersion}; run aztec_sync_repos again before searching.`); + } } lines.push(""); @@ -81,7 +84,8 @@ export function formatStatus(status: { for (const repo of status.repos) { const icon = repo.cloned ? "✓" : "○"; const commit = repo.commit ? ` (${repo.commit})` : ""; - lines.push(` ${icon} ${repo.name}${commit}`); + const inactive = status.syncMetadata?.repositories && !status.syncMetadata.repositories[repo.name]; + lines.push(` ${icon} ${repo.name}${commit}${inactive ? " [not active for this release]" : ""}`); lines.push(` ${repo.description}`); } diff --git a/src/utils/git.ts b/src/utils/git.ts index 6b15179..5b67d75 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -130,6 +130,12 @@ export function isRepoCloned(repoName: string): boolean { return existsSync(join(repoPath, ".git")); } +function removeAttemptOwnedCheckout(path: string, existedBeforeAttempt: boolean, isTemporaryReplacement: boolean): void { + if (isTemporaryReplacement || !existedBeforeAttempt) { + rmSync(path, { recursive: true, force: true }); + } +} + /** * Clone a repository with optional sparse checkout and tag support */ @@ -157,6 +163,7 @@ export async function cloneRepo( // Clone to a temp dir when replacing an existing repo, so failure leaves the old repo intact const clonePath = needsForceReclone ? repoPath + ".tmp" : repoPath; + const clonePathExisted = existsSync(clonePath); if (needsForceReclone) { log?.(`${config.name}: Safe re-clone (force=${force}, versionMismatch=${versionMismatch})`, "debug"); @@ -183,21 +190,21 @@ export async function cloneRepo( if (isSparse) { // Clone with sparse checkout for large repos if (config.commit) { - // For commits, we need full history to fetch the commit await git.clone(config.url, clonePath, [ "--filter=blob:none", "--sparse", "--no-checkout", + "--depth=1", ]); const repoGit = simpleGit({ baseDir: clonePath, progress: progressHandler }); await repoGit.raw(["config", "gc.auto", "0"]); - log?.(`${config.name}: Setting sparse checkout paths: ${config.sparse!.join(", ")}`, "debug"); - await repoGit.raw(["sparse-checkout", "set", "--skip-checks", ...config.sparse!]); log?.(`${config.name}: Fetching commit ${config.commit.substring(0, 7)}`, "info"); - await repoGit.fetch(["origin", config.commit]); + await repoGit.fetch(["--depth=1", "origin", config.commit]); log?.(`${config.name}: Checking out commit`, "debug"); await repoGit.checkout(config.commit); + log?.(`${config.name}: Setting sparse checkout paths: ${config.sparse!.join(", ")}`, "debug"); + await repoGit.raw(["sparse-checkout", "set", "--skip-checks", ...config.sparse!]); } else if (config.tag) { await git.clone(config.url, clonePath, [ "--filter=blob:none", @@ -250,10 +257,7 @@ export async function cloneRepo( } } } catch (error) { - // On failure: clean up temp dir, leave original repo intact - if (needsForceReclone) { - rmSync(clonePath, { recursive: true, force: true }); - } + removeAttemptOwnedCheckout(clonePath, clonePathExisted, needsForceReclone); throw error; } diff --git a/src/utils/release.ts b/src/utils/release.ts new file mode 100644 index 0000000..f17962e --- /dev/null +++ b/src/utils/release.ts @@ -0,0 +1,49 @@ +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { simpleGit } from "simple-git"; +import type { Logger } from "./git.js"; + +export interface ReleaseCommits { + packagesCommit: string; + nodeCommit: string; + noirCommit: string; +} + +/** Resolve gitlinks without checking out aztec-packages or downloading source blobs. */ +export async function resolveReleaseCommits(version: string, log?: Logger): Promise { + if (!/^v?\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)) { + throw new Error(`Invalid Aztec release tag: ${version}`); + } + const directory = mkdtempSync(join(tmpdir(), "aztec-mcp-release-")); + try { + const git = simpleGit(directory); + await git.init(true); + await git.addRemote("origin", "https://github.com/AztecProtocol/aztec-packages"); + const fetchTag = (tag: string) => git.fetch([ + "--depth=1", "--filter=blob:none", "--no-tags", "origin", `refs/tags/${tag}`, + ]); + log?.(`Resolving labs and Noir commits for ${version}`, "info"); + try { + await fetchTag(version); + } catch { + await fetchTag(version.startsWith("v") ? version.slice(1) : `v${version}`); + } + const packagesCommit = (await git.revparse(["FETCH_HEAD^{commit}"])).trim(); + const gitlink = async (path: string): Promise => { + const entry = await git.raw(["ls-tree", packagesCommit, "--", path]); + const match = entry.match(/^160000 commit ([a-f0-9]{40})\t([^\n]+)\n?$/); + if (!match || match[2] !== path) { + throw new Error(`${version} has no ${path} gitlink; cannot resolve a version-aligned checkout`); + } + return match[1]; + }; + return { + packagesCommit, + nodeCommit: await gitlink("labs"), + noirCommit: await gitlink("noir/noir-repo"), + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/src/utils/search.ts b/src/utils/search.ts index f261b80..cbd9fb5 100644 --- a/src/utils/search.ts +++ b/src/utils/search.ts @@ -6,6 +6,7 @@ import { execFileSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { join, relative, extname } from "path"; import { globbySync } from "globby"; +import { getActiveRepoNames, getCodeRepo, getDocsSource, narrowDocsPaths } from "./sources.js"; import { REPOS_DIR, getRepoPath } from "./git.js"; export interface SearchResult { @@ -36,6 +37,12 @@ export function searchCode( ): SearchResult[] { const { filePattern = "*.nr", repo, maxResults = 50, caseSensitive = false } = options; + const active = getActiveRepoNames(); + if (active && repo && !active.includes(repo.split("/")[0])) return []; + if (active && !repo) { + return active.flatMap((name) => searchCode(query, { ...options, repo: name })) + .sort((a, b) => getResultPriority(a) - getResultPriority(b)).slice(0, maxResults); + } const searchPath = repo ? getRepoPath(repo) : REPOS_DIR; if (!existsSync(searchPath)) { @@ -90,6 +97,13 @@ export function searchDocs( ): SearchResult[] { const { section, maxResults = 30 } = options; + const source = getDocsSource(); + if (source.paths) { + return narrowDocsPaths(source.paths, section) + .flatMap((repo) => searchCode(query, { filePattern: "*.{md,mdx}", repo, maxResults })) + .slice(0, maxResults); + } + // Search versioned docs under developer_versioned_docs, narrowing by section if possible let repo: string | undefined; const versionedDocsGlob = join(REPOS_DIR, "aztec-packages-docs", "docs", "developer_versioned_docs"); @@ -120,21 +134,23 @@ export function searchDocs( export function listExamples(category?: string): FileInfo[] { const examples: FileInfo[] = []; + const active = getActiveRepoNames(); + const codeRepo = getCodeRepo(); + // Search in aztec-examples const examplesPath = getRepoPath("aztec-examples"); - if (existsSync(examplesPath)) { + if ((!active || active.includes("aztec-examples")) && existsSync(examplesPath)) { const contracts = findContracts(examplesPath, "aztec-examples"); examples.push(...contracts); } - // Search in aztec-packages noir-contracts const noirContractsPath = join( - getRepoPath("aztec-packages"), + getRepoPath(codeRepo), "noir-projects", "noir-contracts" ); if (existsSync(noirContractsPath)) { - const contracts = findContracts(noirContractsPath, "aztec-packages"); + const contracts = findContracts(noirContractsPath, codeRepo); examples.push(...contracts); } @@ -198,7 +214,7 @@ export function findExample(name: string): FileInfo | null { export function getResultPriority(result: SearchResult): number { const { repo, file } = result; - if (repo === "aztec-packages") { + if (repo === "aztec-packages" || repo === "aztec-node") { if (/\baztec-nr\b/.test(file) || /\byarn-project\b/.test(file)) return 1; if (/\bnoir-contracts\b/.test(file)) return 2; // Other aztec-packages paths (boxes, playground, etc.) diff --git a/src/utils/sources.ts b/src/utils/sources.ts new file mode 100644 index 0000000..22055d0 --- /dev/null +++ b/src/utils/sources.ts @@ -0,0 +1,55 @@ +import { existsSync } from "fs"; +import { join } from "path"; +import { getRepoPath } from "./git.js"; +import { getAztecRepos } from "../repos/config.js"; +import { readSyncMetadata } from "./sync-metadata.js"; + +export function getSourceMetadata() { + const metadata = readSyncMetadata(); + if (metadata?.pendingVersion) { + throw new Error(`Sync for ${metadata.pendingVersion} is incomplete. Run aztec_sync_repos again before searching.`); + } + return metadata; +} + +/** Undefined retains legacy discovery; split releases search only recorded checkouts. */ +export function getActiveRepoNames(): string[] | undefined { + const metadata = getSourceMetadata(); + if (metadata?.repositories) return Object.keys(metadata.repositories); + if (metadata?.aztecVersion) { + return getAztecRepos(metadata.aztecVersion).flatMap((repo) => + repo.sparsePathOverrides ? [repo.name, `${repo.name}-docs`] : [repo.name]); + } + return undefined; +} + +export function getCodeRepo(): string { + return getSourceMetadata()?.repositories?.["aztec-node"] ? "aztec-node" : "aztec-packages"; +} + +export function getDocsSource(): { repo: string; paths?: string[]; notice: string } { + const metadata = getSourceMetadata(); + const commit = metadata?.repositories?.["aztec-node"]; + if (!commit) return { repo: "aztec-packages-docs", notice: "" }; + + return getNodeDocsSource(metadata.aztecVersion, commit); +} + +export function getNodeDocsSource(version: string, commit: string): { repo: string; paths: string[]; notice: string } { + const tag = version.startsWith("v") ? version : `v${version}`; + const snapshot = `aztec-node/docs/developer_versioned_docs/version-${tag}`; + if (existsSync(getRepoPath(snapshot))) { + return { repo: "aztec-node", paths: [snapshot], notice: `Documentation snapshot ${tag} from aztec-node @ ${commit}.` }; + } + return { + repo: "aztec-node", + paths: ["aztec-node/docs/docs-developers/docs", "aztec-node/docs/docs-operate", "aztec-node/docs/docs-participate"], + notice: `No exact ${tag} documentation snapshot; using unversioned docs from aztec-node @ ${commit}.`, + }; +} + +export function narrowDocsPaths(paths: string[], section?: string): string[] { + if (!section) return paths; + const matching = paths.map((path) => join(path, section)).filter((path) => existsSync(getRepoPath(path))); + return matching.length ? matching : paths; +} diff --git a/src/utils/sync-metadata.ts b/src/utils/sync-metadata.ts index f51102e..9eaeba1 100644 --- a/src/utils/sync-metadata.ts +++ b/src/utils/sync-metadata.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs"; +import { readFileSync, writeFileSync, existsSync, readdirSync, renameSync, rmSync } from "fs"; import { join } from "path"; import { REPOS_DIR } from "./git.js"; import { MCP_VERSION } from "../version.js"; @@ -7,6 +7,12 @@ export interface SyncMetadata { mcpVersion: string; syncedAt: string; aztecVersion: string; + /** Active checkouts and their exact commits for a split-repository release. */ + repositories?: Record; + /** Blocks version-sensitive reads until a split sync completes. */ + pendingVersion?: string; + /** Requested checkouts to retry if a migration is interrupted. */ + pendingRepositories?: string[]; autoResyncAttempt?: { targetMcpVersion: string; attemptedAt: string; @@ -32,14 +38,35 @@ export function invalidateSyncStateCache(): void { cachedSyncState = null; } -export function writeSyncMetadata(aztecVersion: string): void { +function replaceMetadataAtomically(metadata: SyncMetadata): void { + const temporaryPath = `${getMetadataPath()}.tmp`; + try { + writeFileSync(temporaryPath, JSON.stringify(metadata, null, 2)); + renameSync(temporaryPath, getMetadataPath()); + } finally { + rmSync(temporaryPath, { force: true }); + } + invalidateSyncStateCache(); +} + +export function writeSyncMetadata(aztecVersion: string, repositories?: Record): void { const metadata: SyncMetadata = { mcpVersion: MCP_VERSION, syncedAt: new Date().toISOString(), aztecVersion, + ...(repositories ? { repositories } : {}), }; - writeFileSync(getMetadataPath(), JSON.stringify(metadata, null, 2)); - invalidateSyncStateCache(); + replaceMetadataAtomically(metadata); +} + +/** Persist intent before replacing checkouts so a failed migration cannot mix releases. */ +export function beginVersionSync(aztecVersion: string, repositories: string[]): void { + const metadata = readSyncMetadata() ?? { + mcpVersion: MCP_VERSION, syncedAt: "", aztecVersion, + }; + metadata.pendingVersion = aztecVersion; + metadata.pendingRepositories = repositories; + replaceMetadataAtomically(metadata); } export function readSyncMetadata(): SyncMetadata | null { @@ -73,8 +100,7 @@ export function writeAutoResyncAttempt( attemptedAt: new Date().toISOString(), result, }; - writeFileSync(getMetadataPath(), JSON.stringify(metadata, null, 2)); - invalidateSyncStateCache(); + replaceMetadataAtomically(metadata); } /** @@ -94,8 +120,7 @@ export function stampMetadataMcpVersion(aztecVersion: string): void { metadata.aztecVersion = aztecVersion; } delete metadata.autoResyncAttempt; - writeFileSync(getMetadataPath(), JSON.stringify(metadata, null, 2)); - invalidateSyncStateCache(); + replaceMetadataAtomically(metadata); } /** @@ -130,6 +155,14 @@ function computeSyncState(): SyncState { return { kind: "noRepos" }; } } + if (metadata.pendingVersion) { + const attempt = metadata.autoResyncAttempt; + if (attempt?.result === "retryable" && + Date.now() - new Date(attempt.attemptedAt).getTime() < 30 * 60_000) { + return { kind: "upToDate" }; + } + return { kind: "needsAutoResync", aztecVersion: metadata.pendingVersion }; + } if (metadata.mcpVersion === MCP_VERSION) { return { kind: "upToDate" }; } diff --git a/src/utils/version-check.ts b/src/utils/version-check.ts index 84023a9..bbeb384 100644 --- a/src/utils/version-check.ts +++ b/src/utils/version-check.ts @@ -1,28 +1,7 @@ -/** - * Version-sync gate between the MCP server's local aztec-packages - * clone and the DocsGPT backend's indexed corpus. - * - * Why: an MCP user with a v4.1.0 clone querying a v4.2.0 corpus will - * get answers from the wrong version of the docs and not realize it. - * Surfacing the mismatch up front lets them re-sync their clone (or - * intentionally cross-query with `allowVersionMismatch: true`). - * - * Design: - * - Per-process, in-memory cache keyed by base URL. 5-minute positive - * TTL — short enough that an operator rolling out a new corpus - * version sees the new value within minutes; long enough that - * repeated tool calls don't pound `/api/version`. - * - 30-second negative TTL for transient errors (network blips, - * temporary 5xx). Short so a Phase-1-deployed-after-Phase-2 MCP - * starts seeing real version data quickly without operator action. - * - 404 → `"unknown"` cached at the positive TTL, since an older - * docsgpt deployment that doesn't ship the endpoint won't suddenly - * start shipping it within seconds. - */ - import { DocsGPTClient, DocsGPTClientError } from "../backends/docsgpt-client.js"; import { DEFAULT_AZTEC_VERSION } from "../repos/config.js"; -import { getRepoTag } from "./git.js"; +import { getSourceMetadata } from "./sources.js"; +import { getRepoCommit, getRepoTag } from "./git.js"; export type VersionGateResult = | { kind: "match"; localVersion: string; corpusVersion: string } @@ -45,20 +24,9 @@ export function _resetVersionCache(): void { cache.clear(); } -/** - * Strip the leading ``v`` and any pre-release suffix. - * ``v4.2.0-aztecnr-rc.2`` → ``4.2.0``. ``v4.2.0`` → ``4.2.0``. - * Used as the equality key. Patch-level differences still compare - * unequal — we deliberately do NOT collapse to major.minor since - * docs/APIs can change at the patch level. - */ +/** Normalize the optional v prefix, preserving release-candidate identity. */ export function normalizeVersion(raw: string | null | undefined): string { - if (!raw) return ""; - let v = raw.trim(); - if (v.startsWith("v")) v = v.slice(1); - const dash = v.indexOf("-"); - if (dash >= 0) v = v.slice(0, dash); - return v; + return (raw ?? "").trim().replace(/^v/, ""); } /** @@ -89,11 +57,19 @@ async function fetchCorpusVersionCached(client: DocsGPTClient): Promise { + const metadata = getSourceMetadata(); + const nodeCommit = metadata?.repositories?.["aztec-node"]; + if (nodeCommit) { + if (await getRepoCommit("aztec-node", true) !== nodeCommit) { + throw new Error("aztec-node does not match the recorded release. Run aztec_sync_repos again."); + } + return metadata.aztecVersion; + } const tag = await getRepoTag("aztec-packages"); return tag ?? DEFAULT_AZTEC_VERSION; } @@ -146,7 +122,7 @@ export function formatMismatchMessage( corpusVersion: string ): string { return [ - `Version mismatch: your MCP server's aztec-packages clone is at ${localVersion},`, + `Version mismatch: your MCP server's local release is ${localVersion},`, `but the DocsGPT corpus is indexed at ${corpusVersion}.`, ``, `Querying across versions can return docs that don't apply to the code on your machine.`, diff --git a/test.mjs b/test.mjs index 940f456..6fae380 100644 --- a/test.mjs +++ b/test.mjs @@ -1,66 +1,57 @@ #!/usr/bin/env node -/** - * Simple test script for Aztec MCP Server functionality - */ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -import { syncRepos, getStatus } from "./dist/tools/index.js"; -import { searchCode, listExamples } from "./dist/utils/search.js"; -import { REPOS_DIR } from "./dist/utils/git.js"; +const temporaryDirectory = process.env.AZTEC_MCP_REPOS_DIR + ? undefined + : mkdtempSync(join(tmpdir(), "aztec-mcp-smoke-")); +if (temporaryDirectory) process.env.AZTEC_MCP_REPOS_DIR = temporaryDirectory; async function test() { - console.log("=== Aztec MCP Server Test ===\n"); - console.log(`Repos directory: ${REPOS_DIR}\n`); - - // Test 1: Check status before sync - console.log("1. Checking initial status..."); - const initialStatus = await getStatus(); - console.log(` Repos configured: ${initialStatus.repos.length}`); - for (const repo of initialStatus.repos) { - console.log(` - ${repo.name}: ${repo.cloned ? "cloned" : "not cloned"}`); - } - console.log(); - - // Test 2: Sync repos (this will take a while) - console.log("2. Syncing repositories (this may take a few minutes)..."); - const syncResult = await syncRepos({ - version: "v4.3.0", - force: true // Force re-clone to get all repos at the tag - }); - console.log(` Success: ${syncResult.success}`); - console.log(` Version: ${syncResult.version}`); - for (const repo of syncResult.repos) { - console.log(` - ${repo.name}: ${repo.status}`); + const { syncRepos, getStatus, searchAztecDocs } = await import("./dist/tools/index.js"); + const { searchCode, listExamples } = await import("./dist/utils/search.js"); + const { REPOS_DIR } = await import("./dist/utils/git.js"); + const { getLocalVersion } = await import("./dist/utils/version-check.js"); + const version = "v6.0.0-rc.6"; + + console.log(`Testing ${version} in ${REPOS_DIR}`); + const syncResult = await syncRepos({ version }); + assert.equal(syncResult.success, true, syncResult.message); + assert.equal(syncResult.metadataSafe, true, syncResult.message); + assert.deepEqual(syncResult.repos.map((repo) => repo.name), ["aztec-node"]); + assert.equal(await getLocalVersion(), version); + if (temporaryDirectory) { + assert.equal(existsSync(join(REPOS_DIR, "aztec-packages")), false); } - console.log(); + console.log(syncResult.message); - // Test 3: Check status after sync - console.log("3. Checking status after sync..."); - const afterStatus = await getStatus(); - for (const repo of afterStatus.repos) { - const commit = repo.commit ? ` (${repo.commit})` : ""; - console.log(` - ${repo.name}: ${repo.cloned ? "cloned" : "not cloned"}${commit}`); - } - console.log(); + const status = await getStatus(); + assert.equal(status.repos.find((repo) => repo.name === "aztec-node")?.cloned, true); - // Test 4: Search for code - console.log("4. Searching for 'PrivateSet' in .nr files..."); - const codeResults = searchCode("PrivateSet", { filePattern: "*.nr", maxResults: 5 }); - console.log(` Found ${codeResults.length} results`); - for (const result of codeResults.slice(0, 3)) { - console.log(` - ${result.file}:${result.line}`); - } - console.log(); + const code = searchCode("PrivateSet", { repo: "aztec-node", filePattern: "*.nr", maxResults: 5 }); + assert.ok(code.length > 0, "Expected code search results"); + assert.ok(code.every((result) => result.repo === "aztec-node")); - // Test 5: List examples - console.log("5. Listing contract examples..."); const examples = listExamples(); - console.log(` Found ${examples.length} examples`); - for (const example of examples.slice(0, 5)) { - console.log(` - ${example.name} (${example.repo})`); - } - console.log(); + assert.ok(examples.length > 0, "Expected contract examples"); + assert.ok(examples.every((example) => example.repo === "aztec-node")); - console.log("=== Tests Complete ==="); + const docs = await searchAztecDocs({ query: "storage", maxResults: 5 }, null); + assert.equal(docs.kind, "ripgrep"); + assert.equal(docs.result.success, true, docs.result.message); + assert.ok(docs.result.results.length > 0, "Expected local documentation search results"); + assert.ok(docs.result.results.every((result) => result.repo === "aztec-node")); + + console.log(`Passed: ${code.length} code matches, ${docs.result.results.length} docs matches, ${examples.length} examples.`); } -test().catch(console.error); +try { + await test(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} finally { + if (temporaryDirectory) rmSync(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/tests/repos/config.test.ts b/tests/repos/config.test.ts index 0d26ab1..9e6e980 100644 --- a/tests/repos/config.test.ts +++ b/tests/repos/config.test.ts @@ -1,112 +1,53 @@ import { describe, it, expect } from "vitest"; -import { - AZTEC_REPOS, - getAztecRepos, - getRepoConfig, - getRepoNames, - DEFAULT_AZTEC_VERSION, -} from "../../src/repos/config.js"; +import { AZTEC_REPOS, getAztecRepos, getRepoConfig, getRepoNames, DEFAULT_AZTEC_VERSION } from "../../src/repos/config.js"; -describe("AZTEC_REPOS", () => { - it("contains 7 repos, each with name/url/description", () => { - expect(AZTEC_REPOS).toHaveLength(7); - for (const repo of AZTEC_REPOS) { - expect(repo.name).toBeTruthy(); - expect(repo.url).toBeTruthy(); - expect(repo.description).toBeTruthy(); - } +describe("v6 repositories", () => { + it("defaults to just aztec-node, with protocol and Noir sources optional", () => { + expect(DEFAULT_AZTEC_VERSION).toBe("v6.0.0-rc.6"); + expect(AZTEC_REPOS.filter((repo) => !repo.optional).map((repo) => repo.name)).toEqual(["aztec-node"]); + expect(getRepoNames()).toEqual(["aztec-node", "aztec-packages", "noir"]); + expect(getRepoConfig("unknown")).toBeUndefined(); }); - it("has sparse checkout arrays on aztec-packages and noir", () => { - const ap = AZTEC_REPOS.find((r) => r.name === "aztec-packages"); - const noir = AZTEC_REPOS.find((r) => r.name === "noir"); - expect(ap?.sparse).toBeInstanceOf(Array); - expect(ap!.sparse!.length).toBeGreaterThan(0); - expect(noir?.sparse).toBeInstanceOf(Array); - expect(noir!.sparse!.length).toBeGreaterThan(0); + it("resolves the node commit during sync instead of applying an Aztec tag to it", () => { + const node = getRepoConfig("aztec-node")!; + expect(node.url).toBe("https://github.com/aztec-labs-eng/aztec-node"); + expect(node.tag).toBeUndefined(); + expect(node.branch).toBeUndefined(); + expect(node.sparse).toContain("noir-projects/noir-contracts"); + expect(node.sparse).toContain("docs/developer_versioned_docs/version-v6.0.0-rc.6"); + expect(node.sparsePathOverrides).toBeUndefined(); }); - it("aztec-packages has sparsePathOverrides for versioned docs on next branch", () => { - const ap = AZTEC_REPOS.find((r) => r.name === "aztec-packages"); - expect(ap?.sparse).not.toContain("docs"); - expect(ap?.sparsePathOverrides).toEqual([ - { - paths: [ - `docs/developer_versioned_docs/version-${DEFAULT_AZTEC_VERSION}`, - "docs/static/aztec-nr-api/devnet", - "docs/static/typescript-api/devnet", - "docs/docs-developers/docs/aztec-nr", - "docs/docs-operate/operators", - ], - branch: "next", - }, - ]); + it("accepts tags without a v prefix while using canonical documentation paths", () => { + const node = getAztecRepos("6.0.0-rc.6")[0]; + expect(node.sparse).toContain("docs/developer_versioned_docs/version-v6.0.0-rc.6"); }); - it('noir and noir-examples have branch: "master"', () => { - const noir = AZTEC_REPOS.find((r) => r.name === "noir"); - const noirExamples = AZTEC_REPOS.find((r) => r.name === "noir-examples"); - expect(noir?.branch).toBe("master"); - expect(noirExamples?.branch).toBe("master"); + it("uses the split protocol layout for optional aztec-packages sources", () => { + const protocol = getRepoConfig("aztec-packages")!; + expect(protocol.optional).toBe(true); + expect(protocol.sparse).toContain("barretenberg/ts/bb.js/src"); + expect(protocol.sparse).not.toContain("yarn-project"); + expect(protocol.sparsePathOverrides).toBeUndefined(); }); }); -describe("getAztecRepos", () => { - it("applies version tag only to AztecProtocol repos without skipVersionTag", () => { - const repos = getAztecRepos(); - const taggedRepos = repos.filter((r) => r.tag !== undefined); - expect(taggedRepos).toHaveLength(4); - for (const repo of taggedRepos) { - expect(repo.tag).toBe(DEFAULT_AZTEC_VERSION); - } - - const gregoswap = repos.find((r) => r.name === "gregoswap"); - expect(gregoswap?.tag).toBeUndefined(); - }); - - it("uses custom version when provided", () => { - const repos = getAztecRepos("v2.0.0"); - const taggedRepos = repos.filter((r) => r.tag !== undefined); - for (const repo of taggedRepos) { - expect(repo.tag).toBe("v2.0.0"); - } +describe("legacy versions", () => { + it("retains the seven-repository layout and documentation overrides", () => { + const repos = getAztecRepos("v4.3.0"); + expect(repos).toHaveLength(7); + expect(repos.some((repo) => repo.name === "aztec-node")).toBe(false); + const packages = repos.find((repo) => repo.name === "aztec-packages")!; + expect(packages.sparsePathOverrides?.[0].paths).toContain("docs/developer_versioned_docs/version-v4.3.0"); }); - it("does not apply tags to noir-lang repos", () => { + it("applies custom tags only to versioned Aztec repositories", () => { const repos = getAztecRepos("v2.0.0"); - const noirRepos = repos.filter((r) => r.url.includes("noir-lang")); - - expect(noirRepos).toHaveLength(2); - - for (const repo of noirRepos) { - expect(repo.tag).toBeUndefined(); - } - }); -}); - -describe("getRepoConfig", () => { - it("returns correct config for aztec-packages", () => { - const config = getRepoConfig("aztec-packages"); - expect(config).toBeDefined(); - expect(config!.name).toBe("aztec-packages"); - expect(config!.url).toContain("AztecProtocol"); - }); - - it("returns undefined for unknown name", () => { - expect(getRepoConfig("nonexistent")).toBeUndefined(); - }); -}); - -describe("getRepoNames", () => { - it("returns all 7 names", () => { - const names = getRepoNames(); - expect(names).toHaveLength(7); - expect(names).toContain("aztec-packages"); - expect(names).toContain("noir"); - expect(names).toContain("noir-examples"); - expect(names).toContain("aztec-examples"); - expect(names).toContain("aztec-starter"); - expect(names).toContain("demo-wallet"); - expect(names).toContain("gregoswap"); + const tagged = repos.filter((repo) => repo.tag); + expect(tagged).toHaveLength(4); + expect(tagged.every((repo) => repo.tag === "v2.0.0")).toBe(true); + expect(repos.filter((repo) => repo.url.includes("noir-lang")).every((repo) => !repo.tag)).toBe(true); + expect(repos.find((repo) => repo.name === "gregoswap")?.tag).toBeUndefined(); }); }); diff --git a/tests/tools/search.test.ts b/tests/tools/search.test.ts index 863d80a..a73b972 100644 --- a/tests/tools/search.test.ts +++ b/tests/tools/search.test.ts @@ -285,14 +285,15 @@ describe("searchAztecDocs — version-sync gate", () => { expect(client.search).not.toHaveBeenCalled(); }); - it("treats `v4.2.0-aztecnr-rc.2` and `v4.2.0` as matching after normalization", async () => { + it("treats `v4.2.0-aztecnr-rc.2` and `v4.2.0` as different releases", async () => { mockGetRepoTag.mockResolvedValue("v4.2.0-aztecnr-rc.2"); const client = makeClient({ getCorpusVersion: vi.fn().mockResolvedValue({ aztec_corpus_version: "v4.2.0" }), }); const result = await searchAztecDocs({ query: "test" }, client); - expect(result.kind).toBe("semantic"); + expect(result.kind).toBe("version-mismatch"); + expect(client.search).not.toHaveBeenCalled(); }); it("on mismatch + useLocalFallback=true, falls through to ripgrep instead of refusing", async () => { diff --git a/tests/tools/sync-split.test.ts b/tests/tools/sync-split.test.ts new file mode 100644 index 0000000..82dbe91 --- /dev/null +++ b/tests/tools/sync-split.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/utils/git.js", () => ({ + cloneRepo: vi.fn(), ensureReposDir: vi.fn(), getRepoCommit: vi.fn(), + getRepoPath: (name: string) => `/fake/repos/${name}`, + getReposStatus: vi.fn(), getNoirCommitFromAztec: vi.fn(), REPOS_DIR: "/fake/repos", +})); +vi.mock("../../src/utils/release.js", () => ({ resolveReleaseCommits: vi.fn() })); +vi.mock("../../src/utils/sync-metadata.js", () => ({ + beginVersionSync: vi.fn(), readSyncMetadata: vi.fn(), writeSyncMetadata: vi.fn(), stampMetadataMcpVersion: vi.fn(), +})); +vi.mock("../../src/utils/error-lookup.js", () => ({ clearErrorCache: vi.fn() })); +vi.mock("fs", () => ({ existsSync: vi.fn(() => false) })); + +import { syncRepos } from "../../src/tools/sync.js"; +import { cloneRepo, getRepoCommit } from "../../src/utils/git.js"; +import { resolveReleaseCommits } from "../../src/utils/release.js"; +import { beginVersionSync, readSyncMetadata, writeSyncMetadata } from "../../src/utils/sync-metadata.js"; + +const version = "v6.0.0-rc.6"; +const commits = { packagesCommit: "a".repeat(40), nodeCommit: "b".repeat(40), noirCommit: "c".repeat(40) }; +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(resolveReleaseCommits).mockResolvedValue(commits); + vi.mocked(cloneRepo).mockResolvedValue("Cloned"); + vi.mocked(readSyncMetadata).mockReturnValue(null); +}); + +describe("lightweight v6 sync", () => { + it("clones only the node pinned by the release and records its identity", async () => { + const result = await syncRepos({}); + expect(resolveReleaseCommits).toHaveBeenCalledWith(version, undefined); + expect(cloneRepo).toHaveBeenCalledOnce(); + expect(cloneRepo).toHaveBeenCalledWith(expect.objectContaining({ + name: "aztec-node", commit: commits.nodeCommit, tag: undefined, branch: undefined, + }), false, undefined); + expect(beginVersionSync).toHaveBeenCalledWith(version, ["aztec-node"]); + expect(writeSyncMetadata).toHaveBeenCalledWith(version, { "aztec-node": commits.nodeCommit }); + expect(result.success).toBe(true); + expect(result.metadataSafe).toBe(true); + expect(result.message).toContain("No exact v6.0.0-rc.6 documentation snapshot"); + }); + + it("adds optional protocol and Noir sources at the same release's commits", async () => { + const result = await syncRepos({ version, repos: ["aztec-packages", "noir"] }); + expect(result.repos.map((repo) => repo.name)).toEqual(["aztec-node", "aztec-packages", "noir"]); + expect(writeSyncMetadata).toHaveBeenCalledWith(version, { + "aztec-node": commits.nodeCommit, "aztec-packages": commits.packagesCommit, noir: commits.noirCommit, + }); + }); + + it("retries requested optional repositories after an interrupted sync", async () => { + vi.mocked(readSyncMetadata).mockReturnValue({ + mcpVersion: "1.6.0", syncedAt: "", aztecVersion: version, + pendingVersion: version, pendingRepositories: ["aztec-node", "noir"], + }); + const result = await syncRepos({ version, force: true }); + expect(result.repos.map((repo) => repo.name)).toEqual(["aztec-node", "noir"]); + expect(writeSyncMetadata).toHaveBeenCalledWith(version, { + "aztec-node": commits.nodeCommit, noir: commits.noirCommit, + }); + }); + + it("keeps recovery incomplete if an optional repository fails again", async () => { + vi.mocked(readSyncMetadata).mockReturnValue({ + mcpVersion: "1.6.0", syncedAt: "", aztecVersion: "v6.0.0-rc.1", + pendingVersion: version, pendingRepositories: ["aztec-node", "aztec-packages"], + }); + vi.mocked(cloneRepo).mockResolvedValueOnce("Cloned").mockRejectedValueOnce(new Error("still offline")); + const result = await syncRepos({ version }); + expect(result.success).toBe(false); + expect(result.metadataSafe).toBe(false); + expect(result.repos.map((repo) => repo.name)).toEqual(["aztec-node", "aztec-packages"]); + expect(writeSyncMetadata).not.toHaveBeenCalled(); + }); + + it("rejects unsupported companion repos without downloading anything", async () => { + const result = await syncRepos({ version, repos: ["aztec-examples"] }); + expect(result.success).toBe(false); + expect(result.message).toContain("Unsupported repositories"); + expect(resolveReleaseCommits).not.toHaveBeenCalled(); + expect(cloneRepo).not.toHaveBeenCalled(); + }); + + it("does not mutate checkouts or metadata when release resolution fails", async () => { + vi.mocked(resolveReleaseCommits).mockRejectedValue(new Error("no labs gitlink")); + const result = await syncRepos({ version }); + expect(result.success).toBe(false); + expect(result.message).toContain("no labs gitlink"); + expect(beginVersionSync).not.toHaveBeenCalled(); + expect(cloneRepo).not.toHaveBeenCalled(); + expect(writeSyncMetadata).not.toHaveBeenCalled(); + }); + + it("leaves an incomplete marker and stops when a checkout fails", async () => { + vi.mocked(cloneRepo).mockRejectedValueOnce(new Error("fetch failed")); + const result = await syncRepos({ version, repos: ["aztec-packages"] }); + expect(result.success).toBe(false); + expect(result.metadataSafe).toBe(false); + expect(beginVersionSync).toHaveBeenCalledWith(version, ["aztec-node", "aztec-packages"]); + expect(cloneRepo).toHaveBeenCalledOnce(); + expect(writeSyncMetadata).not.toHaveBeenCalled(); + }); + + it("does not claim completion after an optional checkout fails", async () => { + vi.mocked(cloneRepo).mockResolvedValueOnce("Cloned").mockRejectedValueOnce(new Error("fetch failed")); + const result = await syncRepos({ version, repos: ["aztec-packages"] }); + expect(result.success).toBe(false); + expect(result.repos).toHaveLength(2); + expect(writeSyncMetadata).not.toHaveBeenCalled(); + }); + + it("refuses to mutate checkouts if the incomplete marker cannot be written", async () => { + vi.mocked(beginVersionSync).mockImplementation(() => { throw new Error("disk full"); }); + const result = await syncRepos({ version }); + expect(result.success).toBe(false); + expect(cloneRepo).not.toHaveBeenCalled(); + }); + + it("reports a final metadata write failure", async () => { + vi.mocked(writeSyncMetadata).mockImplementation(() => { throw new Error("disk full"); }); + const result = await syncRepos({ version }); + expect(result.metadataSafe).toBe(false); + expect(result.message).toContain("failed to persist release metadata"); + }); + + it("keeps previously enabled optional sources only when their commits still match", async () => { + vi.mocked(readSyncMetadata).mockReturnValue({ + mcpVersion: "1.6.0", syncedAt: "", aztecVersion: version, + repositories: { "aztec-node": commits.nodeCommit, "aztec-packages": commits.packagesCommit, noir: commits.noirCommit }, + }); + vi.mocked(getRepoCommit).mockImplementation(async (name) => name === "aztec-packages" ? commits.packagesCommit : null); + await syncRepos({ version }); + expect(writeSyncMetadata).toHaveBeenCalledWith(version, { + "aztec-node": commits.nodeCommit, "aztec-packages": commits.packagesCommit, + }); + }); + + it("drops old optional checkouts from active sources when changing releases", async () => { + vi.mocked(readSyncMetadata).mockReturnValue({ + mcpVersion: "1.6.0", syncedAt: "", aztecVersion: "v6.0.0-rc.1", + repositories: { "aztec-node": "old", "aztec-packages": "old" }, + }); + await syncRepos({ version }); + expect(writeSyncMetadata).toHaveBeenCalledWith(version, { "aztec-node": commits.nodeCommit }); + }); +}); diff --git a/tests/tools/sync.test.ts b/tests/tools/sync.test.ts index 1b8ebf9..4a9f654 100644 --- a/tests/tools/sync.test.ts +++ b/tests/tools/sync.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +const mockBeginVersionSync = vi.fn(); const mockCloneRepo = vi.fn(); const mockGetReposStatus = vi.fn(); const mockGetNoirCommitFromAztec = vi.fn(); @@ -78,6 +79,7 @@ vi.mock("../../src/repos/config.js", () => ({ })); vi.mock("../../src/utils/git.js", () => ({ + ensureReposDir: vi.fn(), cloneRepo: (...args: any[]) => mockCloneRepo(...args), getReposStatus: (...args: any[]) => mockGetReposStatus(...args), getNoirCommitFromAztec: () => mockGetNoirCommitFromAztec(), @@ -86,6 +88,7 @@ vi.mock("../../src/utils/git.js", () => ({ })); vi.mock("../../src/utils/sync-metadata.js", () => ({ + beginVersionSync: (...args: any[]) => mockBeginVersionSync(...args), writeSyncMetadata: (...args: any[]) => mockWriteSyncMetadata(...args), stampMetadataMcpVersion: (...args: any[]) => mockStampMetadataMcpVersion(...args), readSyncMetadata: () => mockReadSyncMetadata(), @@ -110,6 +113,8 @@ beforeEach(() => { mockCloneRepo.mockResolvedValue("Cloned"); mockGetNoirCommitFromAztec.mockResolvedValue(null); mockWriteSyncMetadata.mockReset(); + mockReadSyncMetadata.mockReturnValue(null); + mockBeginVersionSync.mockReset(); mockExistsSync.mockReturnValue(true); }); @@ -437,3 +442,42 @@ describe("getStatus", () => { expect(status.syncMetadata).toBeNull(); }); }); + + +describe("downgrading from a split release", () => { + beforeEach(() => { + mockReadSyncMetadata.mockReturnValue({ + mcpVersion: "1.6.0", syncedAt: "", aztecVersion: "v6.0.0-rc.6", + repositories: { "aztec-node": "node-sha", "aztec-packages": "protocol-sha" }, + }); + }); + + it("rejects partial downgrades before replacing any checkout", async () => { + const result = await syncRepos({ version: "v4.3.0", repos: ["aztec-packages"] }); + expect(result.success).toBe(false); + expect(result.message).toContain("requires a full sync"); + expect(mockCloneRepo).not.toHaveBeenCalled(); + expect(mockStampMetadataMcpVersion).not.toHaveBeenCalled(); + }); + + it("marks the downgrade incomplete before checkout mutations and retains the marker on failure", async () => { + mockCloneRepo.mockImplementation(async (config) => { + expect(mockBeginVersionSync).toHaveBeenCalledWith("v4.3.0", ["aztec-packages", "aztec-examples", "noir"]); + if (config.name === "aztec-examples") throw new Error("network unavailable"); + return "Cloned"; + }); + const result = await syncRepos({ version: "v4.3.0" }); + expect(result.success).toBe(false); + expect(result.metadataSafe).toBe(false); + expect(mockWriteSyncMetadata).not.toHaveBeenCalled(); + expect(mockStampMetadataMcpVersion).not.toHaveBeenCalled(); + }); + + it("replaces split metadata only after all legacy sources succeed", async () => { + const result = await syncRepos({ version: "v4.3.0" }); + expect(result.success).toBe(true); + expect(result.metadataSafe).toBe(true); + expect(mockBeginVersionSync).toHaveBeenCalled(); + expect(mockWriteSyncMetadata).toHaveBeenCalledWith("v4.3.0"); + }); +}); diff --git a/tests/utils/git.test.ts b/tests/utils/git.test.ts index dd639de..7539e59 100644 --- a/tests/utils/git.test.ts +++ b/tests/utils/git.test.ts @@ -201,10 +201,25 @@ describe("cloneRepo", () => { expect.arrayContaining(["--filter=blob:none", "--sparse", "--no-checkout"]) ); expect(mockGitInstance.raw).toHaveBeenCalledWith(["config", "gc.auto", "0"]); - expect(mockGitInstance.fetch).toHaveBeenCalledWith(["origin", "abc123def"]); + expect(mockGitInstance.fetch).toHaveBeenCalledWith(["--depth=1", "origin", "abc123def"]); expect(mockGitInstance.checkout).toHaveBeenCalledWith("abc123def"); }); + it("removes an incomplete fresh checkout if sparse population fails after checkout", async () => { + mockExistsSync.mockReturnValue(false); + mockGitInstance.clone.mockResolvedValue(undefined); + mockGitInstance.fetch.mockResolvedValue(undefined); + mockGitInstance.checkout.mockResolvedValue(undefined); + mockGitInstance.raw.mockImplementation(async (args: string[]) => { + if (args[0] === "sparse-checkout") throw new Error("blob download failed"); + }); + await expect(cloneRepo({ ...sparseConfig, tag: undefined, commit: "a".repeat(40) })) + .rejects.toThrow("blob download failed"); + expect(mockGitInstance.checkout).toHaveBeenCalled(); + expect(mockRmSync).toHaveBeenCalledWith(`${REPOS_DIR}/aztec-packages`, { recursive: true, force: true }); + mockGitInstance.raw.mockReset(); + }); + it("sparse + branch: clones with depth=1 and -b flag", async () => { mockExistsSync.mockReturnValue(false); mockGitInstance.clone.mockResolvedValue(undefined); @@ -314,14 +329,7 @@ describe("cloneRepo", () => { }); it("force=true clones to temp dir then swaps", async () => { - // existsSync calls: - // 1) needsReclone -> isRepoCloned(.git) -> false (needs reclone) - // 2) existsSync(repoPath) for needsForceReclone -> true (repo exists) - // 3) existsSync(repoPath) before swap -> true (old checkout exists) - mockExistsSync - .mockReturnValueOnce(false) // needsReclone -> isRepoCloned -> not at right version - .mockReturnValueOnce(true) // existsSync(repoPath) -> repo exists, so needsForceReclone=true - .mockReturnValueOnce(true); // existsSync(repoPath) before swap -> old checkout exists + mockExistsSync.mockImplementation((path) => String(path) === `${REPOS_DIR}/aztec-packages`); mockGitInstance.clone.mockResolvedValue(undefined); mockGitInstance.raw.mockResolvedValue(undefined); diff --git a/tests/utils/release.test.ts b/tests/utils/release.test.ts new file mode 100644 index 0000000..b2008a2 --- /dev/null +++ b/tests/utils/release.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +const git = vi.hoisted(() => ({ init: vi.fn(), addRemote: vi.fn(), fetch: vi.fn(), revparse: vi.fn(), raw: vi.fn() })); +vi.mock("simple-git", () => ({ simpleGit: vi.fn(() => git) })); +vi.mock("fs", () => ({ mkdtempSync: vi.fn(() => "/tmp/release-test"), rmSync: vi.fn() })); +import { rmSync } from "fs"; +import { resolveReleaseCommits } from "../../src/utils/release.js"; + +const packages = "a".repeat(40); +const node = "b".repeat(40); +const noir = "c".repeat(40); +beforeEach(() => { + vi.resetAllMocks(); + git.revparse.mockResolvedValue(packages + "\n"); + git.raw.mockImplementation(async (args: string[]) => { + const path = args.at(-1); + return `160000 commit ${path === "labs" ? node : noir}\t${path}\n`; + }); +}); + +describe("release resolution", () => { + it("reads exact gitlinks from the fetched tag without checking out sources", async () => { + expect(await resolveReleaseCommits("v6.0.0-rc.6")).toEqual({ packagesCommit: packages, nodeCommit: node, noirCommit: noir }); + expect(git.init).toHaveBeenCalledWith(true); + expect(git.fetch).toHaveBeenCalledWith(["--depth=1", "--filter=blob:none", "--no-tags", "origin", "refs/tags/v6.0.0-rc.6"]); + expect(git.raw).toHaveBeenCalledWith(["ls-tree", packages, "--", "labs"]); + expect(rmSync).toHaveBeenCalledWith("/tmp/release-test", { recursive: true, force: true }); + }); + + it("tries the alternate v prefix when the requested tag is absent", async () => { + git.fetch.mockRejectedValueOnce(new Error("no tag")); + await resolveReleaseCommits("6.0.0-rc.6"); + expect(git.fetch.mock.calls[1][0].at(-1)).toBe("refs/tags/v6.0.0-rc.6"); + }); + + it("fails rather than using main when the labs gitlink is absent", async () => { + git.raw.mockResolvedValue(""); + await expect(resolveReleaseCommits("v6.0.0-rc.6")).rejects.toThrow("no labs gitlink"); + expect(rmSync).toHaveBeenCalled(); + }); + + it("rejects ordinary directories or blobs in place of the submodule", async () => { + git.raw.mockResolvedValue(`040000 tree ${node}\tlabs\n`); + await expect(resolveReleaseCommits("v6.0.0-rc.6")).rejects.toThrow("no labs gitlink"); + }); + + it("cleans up temporary Git data when fetching fails", async () => { + git.fetch.mockRejectedValue(new Error("network unavailable")); + await expect(resolveReleaseCommits("v6.0.0-rc.6")).rejects.toThrow("network unavailable"); + expect(rmSync).toHaveBeenCalled(); + }); + + it("rejects invalid release refs before invoking Git", async () => { + await expect(resolveReleaseCommits("--upload-pack=other")).rejects.toThrow("Invalid Aztec release tag"); + expect(git.init).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/search.test.ts b/tests/utils/search.test.ts index bee2d0d..d57b320 100644 --- a/tests/utils/search.test.ts +++ b/tests/utils/search.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +vi.mock("../../src/utils/sync-metadata.js", () => ({ readSyncMetadata: vi.fn(() => null) })); + // Mock dependencies before importing the module under test vi.mock("child_process", () => ({ execFileSync: vi.fn(), diff --git a/tests/utils/split-search.test.ts b/tests/utils/split-search.test.ts new file mode 100644 index 0000000..8071456 --- /dev/null +++ b/tests/utils/split-search.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; + +const directory = mkdtempSync(join(tmpdir(), "aztec-mcp-search-test-")); +vi.stubEnv("AZTEC_MCP_REPOS_DIR", directory); +const { searchCode, searchDocs, listExamples, getResultPriority } = await import("../../src/utils/search.js"); +const { getDocsSource, getActiveRepoNames } = await import("../../src/utils/sources.js"); +const { lookupError, clearErrorCache } = await import("../../src/utils/error-lookup.js"); +const { searchAztecDocs } = await import("../../src/tools/search.js"); +const repos = join(directory, "repos"); +const version = "v6.0.0-rc.6"; +const commit = "b".repeat(40); +const snapshot = "aztec-node/docs/developer_versioned_docs/version-v6.0.0-rc.6"; + +function file(path: string, contents: string) { + const full = join(repos, path); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, contents); +} +function metadata(extra: object = {}) { + file(".sync-metadata.json", JSON.stringify({ + mcpVersion: "1.6.0", syncedAt: "", aztecVersion: version, + repositories: { "aztec-node": commit }, ...extra, + })); +} + +beforeAll(() => { + file("aztec-node/noir-projects/noir-contracts/token/src/main.nr", "contract Token { fn transfer() {} }"); + file("aztec-node/yarn-project/aztec.js/src/index.ts", "export function transfer() {}"); + file("aztec-node/docs/docs-developers/docs/tutorials/token.md", "Current token tutorial"); + file("aztec-node/docs/developer_versioned_docs/version-v5.2.0/tutorials/token.md", "Stale token tutorial"); + file("aztec-packages/yarn-project/old.ts", "export function transfer() {}"); + file("aztec-examples/stale/src/main.nr", "contract Stale { fn transfer() {} }"); + file("aztec-packages-docs/docs/old.md", "Stale token tutorial"); + file("aztec-node/.git/fixture", ""); + file("aztec-node/yarn-project/stdlib/src/tx/validator/error_texts.ts", + "export const TX_ERROR_V6_FIXTURE = 'v6 fixture rejection';"); + file("aztec-packages/l1-contracts/src/core/libraries/Errors.sol", "error StaleFixtureError();"); +}); +beforeEach(() => { + metadata(); + clearErrorCache(); + rmSync(join(repos, snapshot), { recursive: true, force: true }); +}); +afterAll(() => { rmSync(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); }); + +describe("split checkout searches with real files", () => { + it("searches only active sources even when older clones remain on disk", () => { + const results = searchCode("transfer", { filePattern: "*.{nr,ts}" }); + expect(results).toHaveLength(2); + expect(results.every((result) => result.repo === "aztec-node")).toBe(true); + expect(searchCode("transfer", { repo: "aztec-packages", filePattern: "*.ts" })).toEqual([]); + }); + + it("lists and ranks contracts and SDK sources from the node checkout", () => { + expect(listExamples().map((example) => [example.repo, example.name])).toEqual([["aztec-node", "token"]]); + expect(getResultPriority({ repo: "aztec-node", file: "aztec-node/yarn-project/aztec.js/src/index.ts", content: "" })).toBe(1); + }); + + it("uses unversioned docs at the pinned commit instead of an unrelated snapshot", async () => { + const results = searchDocs("token", { section: "tutorials" }); + expect(results).toHaveLength(1); + expect(results[0].content).toContain("Current"); + expect(getDocsSource().notice).toContain("No exact v6.0.0-rc.6"); + const response = await searchAztecDocs({ query: "token" }, null); + expect(response.kind).toBe("ripgrep"); + if (response.kind === "ripgrep") { + expect(response.result.success).toBe(true); + expect(response.result.message).toContain(commit); + } + }); + + it("prefers an exact snapshot when one exists", () => { + file(`${snapshot}/tutorials/token.md`, "Exact token tutorial"); + expect(searchDocs("token").map((result) => result.content)).toEqual(["Exact token tutorial"]); + expect(getDocsSource().notice).toContain("Documentation snapshot v6.0.0-rc.6"); + }); + + it("reads v6 TX errors without parsing a stale optional protocol checkout", () => { + expect(lookupError("v6 fixture rejection").catalogMatches.some((match) => match.entry.name === "TX_ERROR_V6_FIXTURE")).toBe(true); + expect(lookupError("StaleFixtureError").catalogMatches.some((match) => match.entry.name === "StaleFixtureError")).toBe(false); + }); + + it("includes explicitly enabled protocol sources", () => { + metadata({ repositories: { "aztec-node": commit, "aztec-packages": "a".repeat(40) } }); + expect(searchCode("transfer", { filePattern: "*.ts" }).map((result) => result.repo)).toContain("aztec-packages"); + }); + + it("excludes the v6 node checkout after a completed downgrade", () => { + metadata({ aztecVersion: "v4.3.0", repositories: undefined }); + const results = searchCode("transfer", { filePattern: "*.ts" }); + expect(results.map((result) => result.repo)).toEqual(["aztec-packages"]); + }); + + it("blocks searches after an interrupted migration", () => { + metadata({ pendingVersion: "v6.0.0-rc.7" }); + expect(() => getActiveRepoNames()).toThrow("Sync for v6.0.0-rc.7 is incomplete"); + expect(() => searchDocs("token")).toThrow("incomplete"); + expect(() => listExamples()).toThrow("incomplete"); + }); +}); diff --git a/tests/utils/sync-metadata.test.ts b/tests/utils/sync-metadata.test.ts index 7a81690..c832035 100644 --- a/tests/utils/sync-metadata.test.ts +++ b/tests/utils/sync-metadata.test.ts @@ -6,6 +6,8 @@ const mockExistsSync = vi.fn(); const mockReaddirSync = vi.fn(); vi.mock("fs", () => ({ + renameSync: vi.fn(), + rmSync: vi.fn(), readFileSync: (...args: any[]) => mockReadFileSync(...args), writeFileSync: (...args: any[]) => mockWriteFileSync(...args), existsSync: (...args: any[]) => mockExistsSync(...args), @@ -20,7 +22,10 @@ vi.mock("../../src/version.js", () => ({ MCP_VERSION: "2.0.0", })); +import { renameSync, rmSync } from "fs"; + import { + beginVersionSync, getMetadataPath, writeSyncMetadata, readSyncMetadata, @@ -47,7 +52,7 @@ describe("writeSyncMetadata", () => { expect(mockWriteFileSync).toHaveBeenCalledOnce(); const [path, content] = mockWriteFileSync.mock.calls[0]; - expect(path).toBe("/fake/repos/.sync-metadata.json"); + expect(path).toBe("/fake/repos/.sync-metadata.json.tmp"); const parsed = JSON.parse(content); expect(parsed.mcpVersion).toBe("2.0.0"); @@ -390,3 +395,32 @@ describe("getSyncState", () => { expect(getSyncState()).toEqual({ kind: "legacyUnknownVersion" }); }); }); + + +describe("split sync recovery", () => { + it("preserves the previous release while marking an incomplete migration", () => { + const previous = { mcpVersion: "2.0.0", syncedAt: "old", aztecVersion: "v4.3.0" }; + mockReadFileSync.mockReturnValue(JSON.stringify(previous)); + beginVersionSync("v6.0.0-rc.6", ["aztec-node", "noir"]); + const pending = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(pending).toEqual({ ...previous, pendingVersion: "v6.0.0-rc.6", pendingRepositories: ["aztec-node", "noir"] }); + mockReadFileSync.mockReturnValue(JSON.stringify(pending)); + expect(getSyncState()).toEqual({ kind: "needsAutoResync", aztecVersion: "v6.0.0-rc.6" }); + }); + + it("replaces pending state with the completed release and exact active commits", () => { + writeSyncMetadata("v6.0.0-rc.6", { "aztec-node": "a".repeat(40) }); + const saved = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(saved.pendingVersion).toBeUndefined(); + expect(saved.repositories).toEqual({ "aztec-node": "a".repeat(40) }); + }); +}); + + +it("does not replace the pending metadata record when the atomic rename fails", () => { + vi.mocked(renameSync).mockImplementationOnce(() => { throw new Error("rename failed"); }); + expect(() => writeSyncMetadata("v6.0.0-rc.6", { "aztec-node": "a".repeat(40) })).toThrow("rename failed"); + expect(mockWriteFileSync.mock.calls[0][0]).toBe("/fake/repos/.sync-metadata.json.tmp"); + expect(renameSync).toHaveBeenCalledWith("/fake/repos/.sync-metadata.json.tmp", "/fake/repos/.sync-metadata.json"); + expect(rmSync).toHaveBeenCalledWith("/fake/repos/.sync-metadata.json.tmp", { force: true }); +}); diff --git a/tests/utils/version-check.test.ts b/tests/utils/version-check.test.ts index c32a9dd..5c2d606 100644 --- a/tests/utils/version-check.test.ts +++ b/tests/utils/version-check.test.ts @@ -2,8 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("../../src/utils/git.js", () => ({ getRepoTag: vi.fn(), + getRepoCommit: vi.fn(), })); +vi.mock("../../src/utils/sync-metadata.js", () => ({ readSyncMetadata: vi.fn(() => null) })); + vi.mock("../../src/repos/config.js", () => ({ DEFAULT_AZTEC_VERSION: "v4.2.0", })); @@ -15,7 +18,8 @@ import { getLocalVersion, _resetVersionCache, } from "../../src/utils/version-check.js"; -import { getRepoTag } from "../../src/utils/git.js"; +import { readSyncMetadata } from "../../src/utils/sync-metadata.js"; +import { getRepoCommit, getRepoTag } from "../../src/utils/git.js"; const mockGetRepoTag = vi.mocked(getRepoTag); @@ -26,6 +30,7 @@ function makeClient(getCorpusVersion: any, baseUrl = "https://test.example.com") beforeEach(() => { vi.clearAllMocks(); _resetVersionCache(); + vi.mocked(readSyncMetadata).mockReturnValue(null); }); describe("normalizeVersion", () => { @@ -33,9 +38,9 @@ describe("normalizeVersion", () => { expect(normalizeVersion("v4.2.0")).toBe("4.2.0"); }); - it("strips pre-release suffix", () => { - expect(normalizeVersion("v4.2.0-aztecnr-rc.2")).toBe("4.2.0"); - expect(normalizeVersion("4.2.0-beta")).toBe("4.2.0"); + it("preserves pre-release suffix", () => { + expect(normalizeVersion("v4.2.0-aztecnr-rc.2")).toBe("4.2.0-aztecnr-rc.2"); + expect(normalizeVersion("4.2.0-beta")).toBe("4.2.0-beta"); }); it("returns empty string for null/empty", () => { @@ -44,8 +49,8 @@ describe("normalizeVersion", () => { expect(normalizeVersion(undefined)).toBe(""); }); - it("treats `v4.2.0-aztecnr-rc.2` and `v4.2.0` as equivalent", () => { - expect(normalizeVersion("v4.2.0-aztecnr-rc.2")).toBe( + it("treats `v4.2.0-aztecnr-rc.2` and `v4.2.0` as distinct", () => { + expect(normalizeVersion("v4.2.0-aztecnr-rc.2")).not.toBe( normalizeVersion("v4.2.0") ); }); @@ -156,3 +161,28 @@ describe("formatMismatchMessage", () => { expect(msg).toContain("aztec_sync_repos"); }); }); + + +describe("split release identity", () => { + it("uses recorded release identity instead of a stale aztec-packages checkout", async () => { + vi.mocked(readSyncMetadata).mockReturnValue({ + mcpVersion: "1.6.0", syncedAt: "", aztecVersion: "v6.0.0-rc.6", + repositories: { "aztec-node": "a".repeat(40) }, + }); + vi.mocked(getRepoCommit).mockResolvedValue("a".repeat(40)); + mockGetRepoTag.mockResolvedValue("v4.3.0"); + expect(await getLocalVersion()).toBe("v6.0.0-rc.6"); + expect(mockGetRepoTag).not.toHaveBeenCalled(); + const client = makeClient(vi.fn().mockResolvedValue({ aztec_corpus_version: "v6.0.0-rc.1" })); + expect((await checkVersionGate(client)).kind).toBe("mismatch"); + }); + + it("rejects an untracked change to the pinned node checkout", async () => { + vi.mocked(readSyncMetadata).mockReturnValue({ + mcpVersion: "1.6.0", syncedAt: "", aztecVersion: "v6.0.0-rc.6", + repositories: { "aztec-node": "a".repeat(40) }, + }); + vi.mocked(getRepoCommit).mockResolvedValue("b".repeat(40)); + await expect(getLocalVersion()).rejects.toThrow("does not match the recorded release"); + }); +});