From 4ec39ba9d8693ed5a9537ffad562390a8a8811c9 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 15 May 2026 21:55:43 +0300 Subject: [PATCH 1/2] fix(query-recipes): make project recipes visible to the CLI parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project recipes at `/.codemap/recipes/.sql` were silently invisible to the CLI's `--recipe ` / `--recipes-json` / `--print-sql ` paths. The MCP and HTTP transports always worked — they bootstrap before reaching the loader. Root cause: `parseQueryRest` validates the recipe id by calling `getQueryRecipeSql(id)` (`cmd-query.ts:461`) BEFORE `runQueryCmd` calls `bootstrapCodemap` (`cmd-query.ts:823`). The loader's `getRegistry()` (`query-recipes.ts:80`) calls `getProjectRoot()`, which throws when `_config` is unset (pre-init), the throw is silently caught, and `projectDir` becomes `undefined`. Result: bundled-only registry at parse time, so the parser fails with `codemap: unknown recipe ""` even when the recipe is present on disk. Fix: - Add `setQueryRecipesProjectRoot(root)` to `query-recipes.ts`. Caller-supplied root takes precedence over the runtime config; cache invalidates on change. - `main.ts` plumbs the resolved `root` (already returned by `parseBootstrapArgs`) into the override right after argv parse. Single source of truth: same root `bootstrapCodemap` would resolve — no parallel heuristic, no new env var. Adds: - `query-recipes.pre-bootstrap.test.ts` — 3 regression tests for the override-only discovery path (no `initCodemap` called). - `.codemap/recipes/src-deprecated.sql` (+ `.md`) — dogfood project recipe that scopes the bundled `deprecated-symbols` audit to `src/`. Both useful in its own right AND a permanent live regression case. --- .../project-recipes-cli-pre-bootstrap.md | 18 +++++ .codemap/recipes/src-deprecated.md | 3 + .codemap/recipes/src-deprecated.sql | 8 ++ .../query-recipes.pre-bootstrap.test.ts | 79 +++++++++++++++++++ src/application/query-recipes.ts | 38 +++++++-- src/cli/main.ts | 9 +++ 6 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 .changeset/project-recipes-cli-pre-bootstrap.md create mode 100644 .codemap/recipes/src-deprecated.md create mode 100644 .codemap/recipes/src-deprecated.sql create mode 100644 src/application/query-recipes.pre-bootstrap.test.ts 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..812f2aec --- /dev/null +++ b/src/application/query-recipes.pre-bootstrap.test.ts @@ -0,0 +1,79 @@ +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; + + beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "codemap-pre-bootstrap-")); + mkdirSync(join(projectRoot, ".codemap", "recipes"), { recursive: true }); + writeFileSync( + join(projectRoot, ".codemap", "recipes", "team-fixture.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("team-fixture"); + expect(getQueryRecipeSql("team-fixture")).toContain("SELECT 1"); + }); + + it("clears project recipes when override is reset to undefined", () => { + setQueryRecipesProjectRoot(projectRoot); + expect(listQueryRecipeIds()).toContain("team-fixture"); + setQueryRecipesProjectRoot(undefined); + expect(listQueryRecipeIds()).not.toContain("team-fixture"); + }); + + it("re-setting the override to a new root invalidates the cache", () => { + setQueryRecipesProjectRoot(projectRoot); + expect(listQueryRecipeIds()).toContain("team-fixture"); + + const otherRoot = mkdtempSync(join(tmpdir(), "codemap-pre-bootstrap-")); + try { + mkdirSync(join(otherRoot, ".codemap", "recipes"), { recursive: true }); + writeFileSync( + join(otherRoot, ".codemap", "recipes", "other-fixture.sql"), + "SELECT 2\n", + ); + setQueryRecipesProjectRoot(otherRoot); + const ids = listQueryRecipeIds(); + expect(ids).toContain("other-fixture"); + expect(ids).not.toContain("team-fixture"); + } 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). From 6ecb862826e55d32eb104f9ce61a634441aa2268 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 16 May 2026 10:33:04 +0300 Subject: [PATCH 2/2] test(query-recipes): use per-test unique fixture ids Hardcoded `team-fixture` / `other-fixture` recipe ids could collide with a future bundled recipe of the same name, making the assertions fail for the wrong reason. Suffix both with a per-test timestamp+random so each run uses distinct ids. Addresses CodeRabbit nitpick on #93. --- .../query-recipes.pre-bootstrap.test.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/application/query-recipes.pre-bootstrap.test.ts b/src/application/query-recipes.pre-bootstrap.test.ts index 812f2aec..27c454a6 100644 --- a/src/application/query-recipes.pre-bootstrap.test.ts +++ b/src/application/query-recipes.pre-bootstrap.test.ts @@ -24,12 +24,19 @@ import { */ 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", "team-fixture.sql"), + join(projectRoot, ".codemap", "recipes", `${primaryId}.sql`), "SELECT 1 AS ok\n", ); _resetRecipesCacheForTests(); @@ -46,32 +53,32 @@ describe("setQueryRecipesProjectRoot — pre-bootstrap CLI parse-phase path", () const projectIds = catalog .filter((c) => c.source === "project") .map((c) => c.id); - expect(projectIds).toContain("team-fixture"); - expect(getQueryRecipeSql("team-fixture")).toContain("SELECT 1"); + 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("team-fixture"); + expect(listQueryRecipeIds()).toContain(primaryId); setQueryRecipesProjectRoot(undefined); - expect(listQueryRecipeIds()).not.toContain("team-fixture"); + expect(listQueryRecipeIds()).not.toContain(primaryId); }); it("re-setting the override to a new root invalidates the cache", () => { setQueryRecipesProjectRoot(projectRoot); - expect(listQueryRecipeIds()).toContain("team-fixture"); + 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", "other-fixture.sql"), + join(otherRoot, ".codemap", "recipes", `${otherId}.sql`), "SELECT 2\n", ); setQueryRecipesProjectRoot(otherRoot); const ids = listQueryRecipeIds(); - expect(ids).toContain("other-fixture"); - expect(ids).not.toContain("team-fixture"); + expect(ids).toContain(otherId); + expect(ids).not.toContain(primaryId); } finally { rmSync(otherRoot, { recursive: true, force: true }); }