Skip to content

Commit d92b917

Browse files
fix(query-recipes): make project recipes visible to the CLI parser (#93)
* fix(query-recipes): make project recipes visible to the CLI parser Project recipes at `<root>/.codemap/recipes/<id>.sql` were silently invisible to the CLI's `--recipe <id>` / `--recipes-json` / `--print-sql <id>` 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 "<project-id>"` 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. * 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.
1 parent d0f450a commit d92b917

6 files changed

Lines changed: 155 additions & 7 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
**Fix: project recipes (`<root>/.codemap/recipes/<id>.sql`) are now visible via the CLI.**
6+
7+
`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.
8+
9+
Fix is surgical:
10+
11+
- 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).
12+
- `cli/main.ts` calls it once right after `parseBootstrapArgs` returns `root`, so every subsequent verb (parser-side and post-bootstrap) sees the same value.
13+
14+
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.
15+
16+
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.
17+
18+
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.

.codemap/recipes/src-deprecated.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
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.
2+
3+
Run via `bun src/index.ts query --recipe src-deprecated`.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- @deprecated symbols defined under `src/` only.
2+
-- Scopes the bundled `deprecated-symbols` recipe to production code, excluding
3+
-- fixtures / tests / docs that may legitimately reference deprecated APIs.
4+
SELECT name, kind, file_path, line_start, doc_comment
5+
FROM symbols
6+
WHERE doc_comment LIKE '%@deprecated%'
7+
AND file_path LIKE 'src/%'
8+
ORDER BY file_path, name;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import {
7+
_resetRecipesCacheForTests,
8+
getQueryRecipeSql,
9+
listQueryRecipeCatalog,
10+
listQueryRecipeIds,
11+
setQueryRecipesProjectRoot,
12+
} from "./query-recipes";
13+
14+
/**
15+
* Regression — `parseQueryRest` validates `--recipe <id>` / `--recipes-json` /
16+
* `--print-sql <id>` BEFORE `runQueryCmd` calls `bootstrapCodemap`, so the
17+
* registry has historically seen `getProjectRoot()` throw and silently
18+
* fallen back to bundled-only. `main.ts` now plumbs the resolved `--root`
19+
* (from `parseBootstrapArgs`) into `setQueryRecipesProjectRoot` so the
20+
* parser-phase discovery sees the project's recipes.
21+
*
22+
* The tests below deliberately DO NOT call `initCodemap()` — they exercise
23+
* the override-only path the CLI parser hits.
24+
*/
25+
describe("setQueryRecipesProjectRoot — pre-bootstrap CLI parse-phase path", () => {
26+
let projectRoot: string;
27+
// Per-test unique ids so the assertions can't collide with a future bundled
28+
// recipe of the same name.
29+
let primaryId: string;
30+
let otherId: string;
31+
32+
beforeEach(() => {
33+
const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
34+
primaryId = `team-fixture-${suffix}`;
35+
otherId = `other-fixture-${suffix}`;
36+
projectRoot = mkdtempSync(join(tmpdir(), "codemap-pre-bootstrap-"));
37+
mkdirSync(join(projectRoot, ".codemap", "recipes"), { recursive: true });
38+
writeFileSync(
39+
join(projectRoot, ".codemap", "recipes", `${primaryId}.sql`),
40+
"SELECT 1 AS ok\n",
41+
);
42+
_resetRecipesCacheForTests();
43+
});
44+
45+
afterEach(() => {
46+
rmSync(projectRoot, { recursive: true, force: true });
47+
_resetRecipesCacheForTests();
48+
});
49+
50+
it("loads project recipes when only the override is set (no initCodemap)", () => {
51+
setQueryRecipesProjectRoot(projectRoot);
52+
const catalog = listQueryRecipeCatalog();
53+
const projectIds = catalog
54+
.filter((c) => c.source === "project")
55+
.map((c) => c.id);
56+
expect(projectIds).toContain(primaryId);
57+
expect(getQueryRecipeSql(primaryId)).toContain("SELECT 1");
58+
});
59+
60+
it("clears project recipes when override is reset to undefined", () => {
61+
setQueryRecipesProjectRoot(projectRoot);
62+
expect(listQueryRecipeIds()).toContain(primaryId);
63+
setQueryRecipesProjectRoot(undefined);
64+
expect(listQueryRecipeIds()).not.toContain(primaryId);
65+
});
66+
67+
it("re-setting the override to a new root invalidates the cache", () => {
68+
setQueryRecipesProjectRoot(projectRoot);
69+
expect(listQueryRecipeIds()).toContain(primaryId);
70+
71+
const otherRoot = mkdtempSync(join(tmpdir(), "codemap-pre-bootstrap-"));
72+
try {
73+
mkdirSync(join(otherRoot, ".codemap", "recipes"), { recursive: true });
74+
writeFileSync(
75+
join(otherRoot, ".codemap", "recipes", `${otherId}.sql`),
76+
"SELECT 2\n",
77+
);
78+
setQueryRecipesProjectRoot(otherRoot);
79+
const ids = listQueryRecipeIds();
80+
expect(ids).toContain(otherId);
81+
expect(ids).not.toContain(primaryId);
82+
} finally {
83+
rmSync(otherRoot, { recursive: true, force: true });
84+
}
85+
});
86+
});

src/application/query-recipes.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,40 @@ export function resolveProjectRecipesDir(
7676
*/
7777
let cachedRegistry: LoadedRecipe[] | undefined;
7878
let cachedRegistryProjectDir: string | undefined;
79+
let projectRootOverride: string | undefined;
7980

80-
function getRegistry(): LoadedRecipe[] {
81-
// `getProjectRoot()` throws if `initCodemap()` hasn't run; that only
82-
// happens for direct unit tests of this module pre-bootstrap. Treat
83-
// that as "no project recipes" — bundled-only registry.
84-
let projectDir: string | undefined;
81+
/**
82+
* Resolve the project root for project-recipe discovery without requiring
83+
* `initCodemap()`. The CLI parser validates `--recipe <id>` / `--recipes-json`
84+
* via `getQueryRecipeSql` / `listQueryRecipeCatalog` BEFORE `bootstrapCodemap`
85+
* runs, so `getProjectRoot()` throws and the loader silently falls back to
86+
* bundled-only. `main.ts` plumbs the already-resolved `--root` (or
87+
* `CODEMAP_ROOT` / `cwd` default from `parseBootstrapArgs`) here right after
88+
* argv parse, giving the loader a project root before bootstrap.
89+
*
90+
* Single source of truth: same `root` value `bootstrapCodemap` resolves to
91+
* — no parallel walk-up heuristic. Cache invalidates when root changes.
92+
*/
93+
export function setQueryRecipesProjectRoot(root: string | undefined): void {
94+
if (projectRootOverride === root) return;
95+
projectRootOverride = root;
96+
cachedRegistry = undefined;
97+
cachedRegistryProjectDir = undefined;
98+
}
99+
100+
function resolveCurrentProjectRoot(): string | undefined {
101+
if (projectRootOverride !== undefined) return projectRootOverride;
85102
try {
86-
projectDir = resolveProjectRecipesDir(getProjectRoot());
103+
return getProjectRoot();
87104
} catch {
88-
projectDir = undefined;
105+
return undefined;
89106
}
107+
}
108+
109+
function getRegistry(): LoadedRecipe[] {
110+
const root = resolveCurrentProjectRoot();
111+
const projectDir =
112+
root !== undefined ? resolveProjectRecipesDir(root) : undefined;
90113

91114
if (cachedRegistry !== undefined && cachedRegistryProjectDir === projectDir) {
92115
return cachedRegistry;
@@ -106,6 +129,7 @@ function getRegistry(): LoadedRecipe[] {
106129
export function _resetRecipesCacheForTests(): void {
107130
cachedRegistry = undefined;
108131
cachedRegistryProjectDir = undefined;
132+
projectRootOverride = undefined;
109133
}
110134

111135
/**

src/cli/main.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ export async function main(): Promise<void> {
3030
return;
3131
}
3232

33+
// Project recipes live at `<root>/.codemap/recipes/<id>.sql`. Argv-parse-time
34+
// validation (`parseQueryRest` calls `getQueryRecipeSql` on `--recipe <id>` /
35+
// `--recipes-json` / `--print-sql`) runs BEFORE `bootstrapCodemap` and would
36+
// otherwise see `getProjectRoot()` throw → silent fallback to bundled-only.
37+
// Plumb the already-resolved root in so parser-side discovery works too.
38+
const { setQueryRecipesProjectRoot } =
39+
await import("../application/query-recipes.js");
40+
setQueryRecipesProjectRoot(root);
41+
3342
// Once-per-process stderr nag if the consumer's pointer files are out
3443
// of date relative to `EXPECTED_POINTER_VERSION`. Cure: `agents init
3544
// --force`. Polite to stdout (warning is stderr only).

0 commit comments

Comments
 (0)