diff --git a/.changeset/project-recipes-cli-pre-bootstrap.md b/.changeset/project-recipes-cli-pre-bootstrap.md new file mode 100644 index 00000000..790ddc1e --- /dev/null +++ b/.changeset/project-recipes-cli-pre-bootstrap.md @@ -0,0 +1,18 @@ +--- +"@stainless-code/codemap": patch +--- + +**Fix: project recipes (`/.codemap/recipes/.sql`) are now visible via the CLI.** + +`parseQueryRest` validates `--recipe ` / `--recipes-json` / `--print-sql ` BEFORE `runQueryCmd` calls `bootstrapCodemap`, so the recipe registry hit `getProjectRoot()` pre-init, the throw was silently caught, and the loader fell back to bundled-only. The MCP and HTTP transports always bootstrap before reaching the loader, so project recipes worked there throughout — only the CLI path was affected. + +Fix is surgical: + +- New `setQueryRecipesProjectRoot(root)` API in `application/query-recipes.ts` — caller-supplied root takes precedence over the runtime config (which isn't initialised yet during argv parse). +- `cli/main.ts` calls it once right after `parseBootstrapArgs` returns `root`, so every subsequent verb (parser-side and post-bootstrap) sees the same value. + +Single source of truth: the override is the same root `bootstrapCodemap` resolves to — no parallel walk-up heuristic, no new env var, no second resolution path. The registry cache invalidates when the override changes. + +Adds regression tests (`query-recipes.pre-bootstrap.test.ts`) exercising the override-only path (no `initCodemap`) plus a dogfood project recipe (`.codemap/recipes/src-deprecated.sql`) that scopes the bundled `deprecated-symbols` audit to `src/` only — useful for codemap's own deprecation lifecycle, and a permanent regression case against this bug recurring. + +Consumers with project recipes authored on 0.6.x–0.7.2 didn't need to wait — recipes worked via MCP / HTTP throughout. After upgrading, the CLI auto-picks them up. diff --git a/.codemap/recipes/src-deprecated.md b/.codemap/recipes/src-deprecated.md new file mode 100644 index 00000000..a784e7b9 --- /dev/null +++ b/.codemap/recipes/src-deprecated.md @@ -0,0 +1,3 @@ +Lists `@deprecated` symbols defined in `src/` only — production code, excluding fixtures, tests, and templates. Companion to the bundled `deprecated-symbols` recipe; useful when planning a removal pass and you want to see exactly what production code still carries the tag. + +Run via `bun src/index.ts query --recipe src-deprecated`. diff --git a/.codemap/recipes/src-deprecated.sql b/.codemap/recipes/src-deprecated.sql new file mode 100644 index 00000000..26084a20 --- /dev/null +++ b/.codemap/recipes/src-deprecated.sql @@ -0,0 +1,8 @@ +-- @deprecated symbols defined under `src/` only. +-- Scopes the bundled `deprecated-symbols` recipe to production code, excluding +-- fixtures / tests / docs that may legitimately reference deprecated APIs. +SELECT name, kind, file_path, line_start, doc_comment +FROM symbols +WHERE doc_comment LIKE '%@deprecated%' + AND file_path LIKE 'src/%' +ORDER BY file_path, name; diff --git a/src/application/query-recipes.pre-bootstrap.test.ts b/src/application/query-recipes.pre-bootstrap.test.ts new file mode 100644 index 00000000..27c454a6 --- /dev/null +++ b/src/application/query-recipes.pre-bootstrap.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + _resetRecipesCacheForTests, + getQueryRecipeSql, + listQueryRecipeCatalog, + listQueryRecipeIds, + setQueryRecipesProjectRoot, +} from "./query-recipes"; + +/** + * Regression — `parseQueryRest` validates `--recipe ` / `--recipes-json` / + * `--print-sql ` BEFORE `runQueryCmd` calls `bootstrapCodemap`, so the + * registry has historically seen `getProjectRoot()` throw and silently + * fallen back to bundled-only. `main.ts` now plumbs the resolved `--root` + * (from `parseBootstrapArgs`) into `setQueryRecipesProjectRoot` so the + * parser-phase discovery sees the project's recipes. + * + * The tests below deliberately DO NOT call `initCodemap()` — they exercise + * the override-only path the CLI parser hits. + */ +describe("setQueryRecipesProjectRoot — pre-bootstrap CLI parse-phase path", () => { + let projectRoot: string; + // Per-test unique ids so the assertions can't collide with a future bundled + // recipe of the same name. + let primaryId: string; + let otherId: string; + + beforeEach(() => { + const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + primaryId = `team-fixture-${suffix}`; + otherId = `other-fixture-${suffix}`; + projectRoot = mkdtempSync(join(tmpdir(), "codemap-pre-bootstrap-")); + mkdirSync(join(projectRoot, ".codemap", "recipes"), { recursive: true }); + writeFileSync( + join(projectRoot, ".codemap", "recipes", `${primaryId}.sql`), + "SELECT 1 AS ok\n", + ); + _resetRecipesCacheForTests(); + }); + + afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); + _resetRecipesCacheForTests(); + }); + + it("loads project recipes when only the override is set (no initCodemap)", () => { + setQueryRecipesProjectRoot(projectRoot); + const catalog = listQueryRecipeCatalog(); + const projectIds = catalog + .filter((c) => c.source === "project") + .map((c) => c.id); + expect(projectIds).toContain(primaryId); + expect(getQueryRecipeSql(primaryId)).toContain("SELECT 1"); + }); + + it("clears project recipes when override is reset to undefined", () => { + setQueryRecipesProjectRoot(projectRoot); + expect(listQueryRecipeIds()).toContain(primaryId); + setQueryRecipesProjectRoot(undefined); + expect(listQueryRecipeIds()).not.toContain(primaryId); + }); + + it("re-setting the override to a new root invalidates the cache", () => { + setQueryRecipesProjectRoot(projectRoot); + expect(listQueryRecipeIds()).toContain(primaryId); + + const otherRoot = mkdtempSync(join(tmpdir(), "codemap-pre-bootstrap-")); + try { + mkdirSync(join(otherRoot, ".codemap", "recipes"), { recursive: true }); + writeFileSync( + join(otherRoot, ".codemap", "recipes", `${otherId}.sql`), + "SELECT 2\n", + ); + setQueryRecipesProjectRoot(otherRoot); + const ids = listQueryRecipeIds(); + expect(ids).toContain(otherId); + expect(ids).not.toContain(primaryId); + } finally { + rmSync(otherRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/src/application/query-recipes.ts b/src/application/query-recipes.ts index 402099ef..379a1ec5 100644 --- a/src/application/query-recipes.ts +++ b/src/application/query-recipes.ts @@ -76,17 +76,40 @@ export function resolveProjectRecipesDir( */ let cachedRegistry: LoadedRecipe[] | undefined; let cachedRegistryProjectDir: string | undefined; +let projectRootOverride: string | undefined; -function getRegistry(): LoadedRecipe[] { - // `getProjectRoot()` throws if `initCodemap()` hasn't run; that only - // happens for direct unit tests of this module pre-bootstrap. Treat - // that as "no project recipes" — bundled-only registry. - let projectDir: string | undefined; +/** + * Resolve the project root for project-recipe discovery without requiring + * `initCodemap()`. The CLI parser validates `--recipe ` / `--recipes-json` + * via `getQueryRecipeSql` / `listQueryRecipeCatalog` BEFORE `bootstrapCodemap` + * runs, so `getProjectRoot()` throws and the loader silently falls back to + * bundled-only. `main.ts` plumbs the already-resolved `--root` (or + * `CODEMAP_ROOT` / `cwd` default from `parseBootstrapArgs`) here right after + * argv parse, giving the loader a project root before bootstrap. + * + * Single source of truth: same `root` value `bootstrapCodemap` resolves to + * — no parallel walk-up heuristic. Cache invalidates when root changes. + */ +export function setQueryRecipesProjectRoot(root: string | undefined): void { + if (projectRootOverride === root) return; + projectRootOverride = root; + cachedRegistry = undefined; + cachedRegistryProjectDir = undefined; +} + +function resolveCurrentProjectRoot(): string | undefined { + if (projectRootOverride !== undefined) return projectRootOverride; try { - projectDir = resolveProjectRecipesDir(getProjectRoot()); + return getProjectRoot(); } catch { - projectDir = undefined; + return undefined; } +} + +function getRegistry(): LoadedRecipe[] { + const root = resolveCurrentProjectRoot(); + const projectDir = + root !== undefined ? resolveProjectRecipesDir(root) : undefined; if (cachedRegistry !== undefined && cachedRegistryProjectDir === projectDir) { return cachedRegistry; @@ -106,6 +129,7 @@ function getRegistry(): LoadedRecipe[] { export function _resetRecipesCacheForTests(): void { cachedRegistry = undefined; cachedRegistryProjectDir = undefined; + projectRootOverride = undefined; } /** diff --git a/src/cli/main.ts b/src/cli/main.ts index 0c57e9c1..4c73fa54 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -30,6 +30,15 @@ export async function main(): Promise { return; } + // Project recipes live at `/.codemap/recipes/.sql`. Argv-parse-time + // validation (`parseQueryRest` calls `getQueryRecipeSql` on `--recipe ` / + // `--recipes-json` / `--print-sql`) runs BEFORE `bootstrapCodemap` and would + // otherwise see `getProjectRoot()` throw → silent fallback to bundled-only. + // Plumb the already-resolved root in so parser-side discovery works too. + const { setQueryRecipesProjectRoot } = + await import("../application/query-recipes.js"); + setQueryRecipesProjectRoot(root); + // Once-per-process stderr nag if the consumer's pointer files are out // of date relative to `EXPECTED_POINTER_VERSION`. Cure: `agents init // --force`. Polite to stdout (warning is stderr only).