Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/project-recipes-cli-pre-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@stainless-code/codemap": patch
---

**Fix: project recipes (`<root>/.codemap/recipes/<id>.sql`) are now visible via the CLI.**

`parseQueryRest` validates `--recipe <id>` / `--recipes-json` / `--print-sql <id>` 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.
3 changes: 3 additions & 0 deletions .codemap/recipes/src-deprecated.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 8 additions & 0 deletions .codemap/recipes/src-deprecated.sql
Original file line number Diff line number Diff line change
@@ -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;
86 changes: 86 additions & 0 deletions src/application/query-recipes.pre-bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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 <id>` / `--recipes-json` /
* `--print-sql <id>` 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 });
}
});
});
38 changes: 31 additions & 7 deletions src/application/query-recipes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` / `--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;
Expand All @@ -106,6 +129,7 @@ function getRegistry(): LoadedRecipe[] {
export function _resetRecipesCacheForTests(): void {
cachedRegistry = undefined;
cachedRegistryProjectDir = undefined;
projectRootOverride = undefined;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export async function main(): Promise<void> {
return;
}

// Project recipes live at `<root>/.codemap/recipes/<id>.sql`. Argv-parse-time
// validation (`parseQueryRest` calls `getQueryRecipeSql` on `--recipe <id>` /
// `--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).
Expand Down
Loading