Skip to content

Commit 114e01c

Browse files
committed
feat(recipes): project-local loader for .codemap/recipes/<id>.sql (Tracer 3 of 6)
Wires up the actually-new user-facing capability per plan §1: teams ship internal SQL recipes via git-tracked .codemap/recipes/<id>.sql files. Three pieces: 1. loadAllRecipes now reads opts.projectDir (was stubbed in Tracer 1). Composes via mergeRecipes — project wins on id collision with shadows: true flag (per Q-E settled). 2. resolveProjectRecipesDir(projectRoot) — root-only resolution per Q-C (no walk-up). Returns undefined if .codemap/recipes/ is missing or is a file rather than a directory; absence is not an error. 3. cli/query-recipes.ts shim's getRegistry() now resolves projectDir via getProjectRoot() (falls back to bundled-only if initCodemap hasn't run — covers direct unit-test paths). Cache key includes projectDir so multi-root sessions (test fixtures) re-resolve cleanly. _resetRecipesCacheForTests clears both halves. 5 new loader-engine tests: bundled-only / bundled+project / shadow detection / sorted ordering / missing-dir. 7 new shim tests: 3 for resolveProjectRecipesDir (absent / present / file-not-dir) + 4 for the end-to-end shim path (bundled-only baseline / project-local id surfaces / project shadows bundled / catalog merging). Project recipes get actions: undefined through Tracer 5 — that tracer adds the YAML frontmatter parser.
1 parent 7fec23e commit 114e01c

4 files changed

Lines changed: 186 additions & 28 deletions

File tree

src/application/recipes-loader.test.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -170,25 +170,54 @@ describe("mergeRecipes", () => {
170170
});
171171
});
172172

173-
describe("loadAllRecipes (Tracer 1 — bundled-only path)", () => {
174-
it("loads bundled, ignores projectDir stub", () => {
175-
const dir = makeRecipeDir("bundled-stub");
173+
describe("loadAllRecipes — bundled + project composition", () => {
174+
it("loads bundled-only when projectDir is undefined", () => {
175+
const dir = makeRecipeDir("bundled-only");
176176
writeFileSync(join(dir, "fan-out.sql"), "SELECT 1\n");
177177
const r = loadAllRecipes({ bundledDir: dir, projectDir: undefined });
178178
expect(r).toHaveLength(1);
179179
expect(r[0]!.source).toBe("bundled");
180180
});
181181

182-
it("ignores a projectDir argument in Tracer 1 (project loader stubbed)", () => {
182+
it("loads bundled + project, sorted by id", () => {
183183
const bundledDir = makeRecipeDir("bundled");
184184
const projectDir = makeRecipeDir("project");
185-
writeFileSync(join(bundledDir, "x.sql"), "SELECT 1\n");
185+
writeFileSync(join(bundledDir, "fan-out.sql"), "SELECT 1\n");
186+
writeFileSync(
187+
join(projectDir, "internal-flaky-tests.sql"),
188+
"SELECT path FROM files\n",
189+
);
190+
const r = loadAllRecipes({ bundledDir, projectDir });
191+
expect(r.map((x) => `${x.id}:${x.source}`)).toEqual([
192+
"fan-out:bundled",
193+
"internal-flaky-tests:project",
194+
]);
195+
});
196+
197+
it("project recipe shadows bundled with same id (project wins, shadows: true)", () => {
198+
const bundledDir = makeRecipeDir("bundled-shadowed");
199+
const projectDir = makeRecipeDir("project-shadowing");
200+
writeFileSync(join(bundledDir, "fan-out.sql"), "SELECT 1\n");
186201
writeFileSync(
187-
join(projectDir, "y.sql"),
188-
"SELECT 2\n",
189-
// Will load in Tracer 3; for now project recipes are silently dropped.
202+
join(projectDir, "fan-out.sql"),
203+
"SELECT 'project version'\n",
190204
);
191205
const r = loadAllRecipes({ bundledDir, projectDir });
192-
expect(r.map((x) => x.id)).toEqual(["x"]);
206+
expect(r).toHaveLength(1);
207+
const recipe = r[0]!;
208+
expect(recipe.source).toBe("project");
209+
expect(recipe.shadows).toBe(true);
210+
expect(recipe.sql).toContain("project version");
211+
});
212+
213+
it("missing .codemap/recipes/ directory is not an error", () => {
214+
const bundledDir = makeRecipeDir("bundled");
215+
writeFileSync(join(bundledDir, "x.sql"), "SELECT 1\n");
216+
const r = loadAllRecipes({
217+
bundledDir,
218+
projectDir: join(workDir, "does-not-exist"),
219+
});
220+
expect(r).toHaveLength(1);
221+
expect(r[0]!.source).toBe("bundled");
193222
});
194223
});

src/application/recipes-loader.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,10 @@ export interface LoadRecipesOpts {
6161
*/
6262
export function loadAllRecipes(opts: LoadRecipesOpts): LoadedRecipe[] {
6363
const bundled = readRecipesFromDir(opts.bundledDir, "bundled");
64-
65-
// Tracer 1: project loader is a stub. Tracer 3 implements it + the
66-
// shadow-flag merge logic (project wins; sets shadows: true when
67-
// an id matches a bundled recipe).
68-
const project: LoadedRecipe[] = [];
69-
64+
const project =
65+
opts.projectDir !== undefined
66+
? readRecipesFromDir(opts.projectDir, "project")
67+
: [];
7068
return mergeRecipes(bundled, project);
7169
}
7270

src/cli/query-recipes.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 { resolveCodemapConfig } from "../config";
7+
import { initCodemap } from "../runtime";
8+
import {
9+
_resetRecipesCacheForTests,
10+
getQueryRecipeActions,
11+
getQueryRecipeSql,
12+
listQueryRecipeCatalog,
13+
listQueryRecipeIds,
14+
resolveProjectRecipesDir,
15+
} from "./query-recipes";
16+
17+
let projectRoot: string;
18+
19+
beforeEach(() => {
20+
projectRoot = mkdtempSync(join(tmpdir(), "query-recipes-"));
21+
initCodemap(resolveCodemapConfig(projectRoot, undefined));
22+
_resetRecipesCacheForTests();
23+
});
24+
25+
afterEach(() => {
26+
rmSync(projectRoot, { recursive: true, force: true });
27+
_resetRecipesCacheForTests();
28+
});
29+
30+
describe("resolveProjectRecipesDir", () => {
31+
it("returns undefined when .codemap/recipes/ is absent", () => {
32+
expect(resolveProjectRecipesDir(projectRoot)).toBeUndefined();
33+
});
34+
35+
it("returns the directory path when present", () => {
36+
const recipesDir = join(projectRoot, ".codemap", "recipes");
37+
mkdirSync(recipesDir, { recursive: true });
38+
expect(resolveProjectRecipesDir(projectRoot)).toBe(recipesDir);
39+
});
40+
41+
it("returns undefined when .codemap/recipes is a file (not directory)", () => {
42+
mkdirSync(join(projectRoot, ".codemap"), { recursive: true });
43+
writeFileSync(join(projectRoot, ".codemap", "recipes"), "not a dir");
44+
expect(resolveProjectRecipesDir(projectRoot)).toBeUndefined();
45+
});
46+
});
47+
48+
describe("query-recipes shim — project recipes via runtime root", () => {
49+
it("bundled-only when no .codemap/recipes/ exists", () => {
50+
const ids = listQueryRecipeIds();
51+
expect(ids).toContain("fan-out");
52+
expect(ids).toContain("deprecated-symbols");
53+
// No project recipes; every entry in the catalog has source: "bundled".
54+
// (catalog shape is the legacy QueryRecipeCatalogEntry through Tracer 4
55+
// — Tracer 4 adds source/body/shadows fields. For now confirm presence.)
56+
expect(ids.length).toBeGreaterThan(0);
57+
});
58+
59+
it("loads project-local recipes from .codemap/recipes/<id>.sql", () => {
60+
const recipesDir = join(projectRoot, ".codemap", "recipes");
61+
mkdirSync(recipesDir, { recursive: true });
62+
writeFileSync(
63+
join(recipesDir, "internal-flaky-tests.sql"),
64+
"SELECT path FROM files WHERE 1=0\n",
65+
);
66+
_resetRecipesCacheForTests();
67+
68+
expect(listQueryRecipeIds()).toContain("internal-flaky-tests");
69+
expect(getQueryRecipeSql("internal-flaky-tests")).toContain("WHERE 1=0");
70+
});
71+
72+
it("project recipe shadows bundled — getQueryRecipeSql returns project version", () => {
73+
const recipesDir = join(projectRoot, ".codemap", "recipes");
74+
mkdirSync(recipesDir, { recursive: true });
75+
writeFileSync(
76+
join(recipesDir, "fan-out.sql"),
77+
"SELECT 'project override' AS marker\n",
78+
);
79+
_resetRecipesCacheForTests();
80+
81+
const sql = getQueryRecipeSql("fan-out");
82+
expect(sql).toContain("project override");
83+
// The bundled fan-out had `actions` (review-coupling) — project version
84+
// doesn't carry actions until Tracer 5 wires YAML frontmatter.
85+
expect(getQueryRecipeActions("fan-out")).toBeUndefined();
86+
});
87+
88+
it("listQueryRecipeCatalog includes project recipes alongside bundled", () => {
89+
const recipesDir = join(projectRoot, ".codemap", "recipes");
90+
mkdirSync(recipesDir, { recursive: true });
91+
writeFileSync(join(recipesDir, "owner-fanout.sql"), "SELECT 1 AS x\n");
92+
_resetRecipesCacheForTests();
93+
94+
const catalog = listQueryRecipeCatalog();
95+
const ids = catalog.map((c) => c.id);
96+
expect(ids).toContain("owner-fanout");
97+
expect(ids).toContain("fan-out");
98+
});
99+
});

src/cli/query-recipes.ts

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { existsSync, statSync } from "node:fs";
12
import { dirname, join } from "node:path";
23
import { fileURLToPath } from "node:url";
34

45
import { loadAllRecipes } from "../application/recipes-loader";
56
import type { LoadedRecipe } from "../application/recipes-loader";
7+
import { getProjectRoot } from "../runtime";
68

79
export type { RecipeAction } from "../application/recipes-loader";
810
import type { RecipeAction } from "../application/recipes-loader";
@@ -38,6 +40,20 @@ export function resolveBundledRecipesDir(): string {
3840
);
3941
}
4042

43+
/**
44+
* Returns `<projectRoot>/.codemap/recipes/` if it exists as a directory,
45+
* else `undefined`. Per plan §9 Q-C, root-only — no walk-up; same root
46+
* the CLI's `--root` / `CODEMAP_ROOT` resolves to.
47+
*/
48+
export function resolveProjectRecipesDir(
49+
projectRoot: string,
50+
): string | undefined {
51+
const dir = join(projectRoot, ".codemap", "recipes");
52+
if (!existsSync(dir)) return undefined;
53+
if (!statSync(dir).isDirectory()) return undefined;
54+
return dir;
55+
}
56+
4157
/**
4258
* Bundled recipe `actions` templates. Per-row hint that surfaces in `--json`
4359
* output so agents see the recommended follow-up alongside each row. Lives
@@ -96,24 +112,39 @@ const BUNDLED_RECIPE_ACTIONS: Record<string, RecipeAction[]> = {
96112
/**
97113
* Module-cached registry — populated lazily on first access (loader is pure;
98114
* the cache means we pay the filesystem read once per process lifetime per
99-
* plan §9 Q-B). Project recipes (Tracer 3) will wire `projectDir` here once
100-
* the bootstrap layer can pass it in.
115+
* plan §9 Q-B). Cache key includes `projectDir` so that a process running
116+
* against multiple roots (test fixtures, multi-root MCP sessions later)
117+
* re-resolves when the root changes.
101118
*/
102119
let cachedRegistry: LoadedRecipe[] | undefined;
120+
let cachedRegistryProjectDir: string | undefined;
103121

104122
function getRegistry(): LoadedRecipe[] {
105-
if (cachedRegistry === undefined) {
106-
cachedRegistry = loadAllRecipes({
107-
bundledDir: resolveBundledRecipesDir(),
108-
projectDir: undefined,
109-
}).map((r) => ({
110-
...r,
111-
// Stitch in the bundled actions map until Tracer 5 lifts them into
112-
// frontmatter on each `.md` file.
113-
actions:
114-
r.source === "bundled" ? BUNDLED_RECIPE_ACTIONS[r.id] : r.actions,
115-
}));
123+
// `getProjectRoot()` throws if `initCodemap()` hasn't run; that only
124+
// happens for direct unit tests of this module pre-bootstrap. Treat
125+
// that as "no project recipes" — bundled-only registry.
126+
let projectDir: string | undefined;
127+
try {
128+
projectDir = resolveProjectRecipesDir(getProjectRoot());
129+
} catch {
130+
projectDir = undefined;
116131
}
132+
133+
if (cachedRegistry !== undefined && cachedRegistryProjectDir === projectDir) {
134+
return cachedRegistry;
135+
}
136+
137+
cachedRegistry = loadAllRecipes({
138+
bundledDir: resolveBundledRecipesDir(),
139+
projectDir,
140+
}).map((r) => ({
141+
...r,
142+
// Stitch in the bundled actions map until Tracer 5 lifts them into
143+
// frontmatter on each `.md` file. Project recipes get `actions: undefined`
144+
// until Tracer 5 plugs the YAML frontmatter parser.
145+
actions: r.source === "bundled" ? BUNDLED_RECIPE_ACTIONS[r.id] : r.actions,
146+
}));
147+
cachedRegistryProjectDir = projectDir;
117148
return cachedRegistry;
118149
}
119150

@@ -122,6 +153,7 @@ function getRegistry(): LoadedRecipe[] {
122153
*/
123154
export function _resetRecipesCacheForTests(): void {
124155
cachedRegistry = undefined;
156+
cachedRegistryProjectDir = undefined;
125157
}
126158

127159
/**

0 commit comments

Comments
 (0)