Skip to content

Commit b19c603

Browse files
committed
feat(recipes): loader scaffold + merge logic (Tracer 1 of 6)
Pure transport-agnostic loader in src/application/recipes-loader.ts (mirrors the cmd-* ↔ *-engine seam from PR #33). Scope per plan §8 Tracer 1: - LoadedRecipe interface (canonical shape; bundled + project share it) - RecipeAction interface lifted from cli/query-recipes.ts (will become the canonical home; query-recipes becomes a shim in Tracer 2) - readRecipesFromDir(dir, source) — reads <id>.sql, pairs with optional <id>.md (description = first non-empty line, body = full text). Returns [] for missing/non-directory paths (project-recipes case where .codemap/recipes/ is absent — not an error). Throws on empty SQL with recipe-aware message - mergeRecipes(bundled, project) — project wins on id collision; sets shadows: true on overriding entries (Q-E settled). Output sorted by id (deterministic catalog order) - loadAllRecipes({bundledDir, projectDir}) — Tracer 1 wires bundled only; projectDir argument accepted but stubbed (returns []). Tracer 3 plugs project loader 15 unit tests cover: missing dir, non-.sql ignore, sql-only loading, sibling-md pairing, heading-strip in description, deterministic id order, empty-sql rejection, comments-then-sql happy path, non-directory passthrough, all 4 merge cases (project-only / bundled-only / shadow / no-overlap), Tracer 1 stub behavior. Layer note: query-recipes.ts (cli/) still owns QUERY_RECIPES + getQueryRecipeSql / getQueryRecipeActions / listQueryRecipeCatalog / listQueryRecipeIds. Tracer 2 migrates them to call into this loader.
1 parent 6e23bf6 commit b19c603

2 files changed

Lines changed: 362 additions & 0 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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+
loadAllRecipes,
8+
mergeRecipes,
9+
readRecipesFromDir,
10+
} from "./recipes-loader";
11+
import type { LoadedRecipe } from "./recipes-loader";
12+
13+
let workDir: string;
14+
15+
beforeEach(() => {
16+
workDir = mkdtempSync(join(tmpdir(), "recipes-loader-"));
17+
});
18+
19+
afterEach(() => {
20+
rmSync(workDir, { recursive: true, force: true });
21+
});
22+
23+
function makeRecipeDir(name: string): string {
24+
const dir = join(workDir, name);
25+
mkdirSync(dir, { recursive: true });
26+
return dir;
27+
}
28+
29+
describe("readRecipesFromDir", () => {
30+
it("returns [] when directory doesn't exist (project-recipes case)", () => {
31+
expect(readRecipesFromDir(join(workDir, "missing"), "project")).toEqual([]);
32+
});
33+
34+
it("ignores non-.sql files", () => {
35+
const dir = makeRecipeDir("ignore-noise");
36+
writeFileSync(join(dir, "fan-out.sql"), "SELECT 1\n");
37+
writeFileSync(join(dir, "README.md"), "# unrelated\n");
38+
writeFileSync(join(dir, ".DS_Store"), "");
39+
const r = readRecipesFromDir(dir, "bundled");
40+
expect(r.map((x) => x.id)).toEqual(["fan-out"]);
41+
});
42+
43+
it("loads SQL only — no sibling .md → description/body/actions undefined", () => {
44+
const dir = makeRecipeDir("sql-only");
45+
writeFileSync(join(dir, "fan-out.sql"), "SELECT 1\n");
46+
const r = readRecipesFromDir(dir, "bundled");
47+
expect(r).toHaveLength(1);
48+
const recipe = r[0]!;
49+
expect(recipe).toMatchObject({
50+
id: "fan-out",
51+
sql: "SELECT 1\n",
52+
description: undefined,
53+
body: undefined,
54+
actions: undefined,
55+
source: "bundled",
56+
shadows: false,
57+
});
58+
});
59+
60+
it("pairs sibling .md — description = first non-empty line, body = full text", () => {
61+
const dir = makeRecipeDir("with-md");
62+
writeFileSync(join(dir, "fan-out.sql"), "SELECT 1\n");
63+
writeFileSync(
64+
join(dir, "fan-out.md"),
65+
"# Fan-out\n\nWhen to use: …\n\nFollow-up SQL: …\n",
66+
);
67+
const r = readRecipesFromDir(dir, "bundled");
68+
expect(r[0]!.description).toBe("Fan-out");
69+
expect(r[0]!.body).toContain("When to use");
70+
});
71+
72+
it("description strips leading `# ` heading marker", () => {
73+
const dir = makeRecipeDir("md-headers");
74+
writeFileSync(join(dir, "x.sql"), "SELECT 1\n");
75+
writeFileSync(join(dir, "x.md"), "## Heading two\n\ncontent\n");
76+
expect(readRecipesFromDir(dir, "bundled")[0]!.description).toBe(
77+
"Heading two",
78+
);
79+
});
80+
81+
it("returns recipes sorted by id (deterministic order)", () => {
82+
const dir = makeRecipeDir("ordering");
83+
writeFileSync(join(dir, "zebra.sql"), "SELECT 1\n");
84+
writeFileSync(join(dir, "alpha.sql"), "SELECT 2\n");
85+
writeFileSync(join(dir, "monkey.sql"), "SELECT 3\n");
86+
const r = readRecipesFromDir(dir, "project");
87+
expect(r.map((x) => x.id)).toEqual(["alpha", "monkey", "zebra"]);
88+
});
89+
90+
it("throws on empty SQL (just whitespace + comments)", () => {
91+
const dir = makeRecipeDir("empty");
92+
writeFileSync(
93+
join(dir, "blank.sql"),
94+
"-- this is just a comment\n \n-- and another\n",
95+
);
96+
expect(() => readRecipesFromDir(dir, "project")).toThrow(/empty/);
97+
});
98+
99+
it("counts SQL with content as non-empty even with leading comments", () => {
100+
const dir = makeRecipeDir("comments-then-sql");
101+
writeFileSync(
102+
join(dir, "x.sql"),
103+
"-- doc comment line\nSELECT path FROM files\n",
104+
);
105+
expect(readRecipesFromDir(dir, "bundled")).toHaveLength(1);
106+
});
107+
108+
it("returns [] for a non-directory path (not an error)", () => {
109+
const filePath = join(workDir, "actually-a-file.txt");
110+
writeFileSync(filePath, "");
111+
expect(readRecipesFromDir(filePath, "bundled")).toEqual([]);
112+
});
113+
});
114+
115+
describe("mergeRecipes", () => {
116+
function recipe(id: string, source: LoadedRecipe["source"]): LoadedRecipe {
117+
return {
118+
id,
119+
sql: `SELECT '${id}'`,
120+
description: undefined,
121+
body: undefined,
122+
actions: undefined,
123+
source,
124+
shadows: false,
125+
};
126+
}
127+
128+
it("project-only — no shadows, no merging", () => {
129+
const r = mergeRecipes(
130+
[],
131+
[recipe("a", "project"), recipe("b", "project")],
132+
);
133+
expect(r.map((x) => `${x.id}:${x.source}:${x.shadows}`)).toEqual([
134+
"a:project:false",
135+
"b:project:false",
136+
]);
137+
});
138+
139+
it("bundled-only — passes through, sorted by id", () => {
140+
const r = mergeRecipes(
141+
[recipe("zebra", "bundled"), recipe("alpha", "bundled")],
142+
[],
143+
);
144+
expect(r.map((x) => x.id)).toEqual(["alpha", "zebra"]);
145+
});
146+
147+
it("project shadows bundled — project wins, shadows: true", () => {
148+
const r = mergeRecipes(
149+
[recipe("fan-out", "bundled"), recipe("fan-in", "bundled")],
150+
[recipe("fan-out", "project")],
151+
);
152+
const fanOut = r.find((x) => x.id === "fan-out")!;
153+
expect(fanOut.source).toBe("project");
154+
expect(fanOut.shadows).toBe(true);
155+
// bundled fan-out is filtered out — only one entry per id.
156+
expect(r.filter((x) => x.id === "fan-out")).toHaveLength(1);
157+
// unrelated bundled recipe still present.
158+
const fanIn = r.find((x) => x.id === "fan-in")!;
159+
expect(fanIn.source).toBe("bundled");
160+
expect(fanIn.shadows).toBe(false);
161+
});
162+
163+
it("project recipe with no bundled match — shadows: false", () => {
164+
const r = mergeRecipes(
165+
[recipe("fan-out", "bundled")],
166+
[recipe("internal-flaky-tests", "project")],
167+
);
168+
const internal = r.find((x) => x.id === "internal-flaky-tests")!;
169+
expect(internal.shadows).toBe(false);
170+
});
171+
});
172+
173+
describe("loadAllRecipes (Tracer 1 — bundled-only path)", () => {
174+
it("loads bundled, ignores projectDir stub", () => {
175+
const dir = makeRecipeDir("bundled-stub");
176+
writeFileSync(join(dir, "fan-out.sql"), "SELECT 1\n");
177+
const r = loadAllRecipes({ bundledDir: dir, projectDir: undefined });
178+
expect(r).toHaveLength(1);
179+
expect(r[0]!.source).toBe("bundled");
180+
});
181+
182+
it("ignores a projectDir argument in Tracer 1 (project loader stubbed)", () => {
183+
const bundledDir = makeRecipeDir("bundled");
184+
const projectDir = makeRecipeDir("project");
185+
writeFileSync(join(bundledDir, "x.sql"), "SELECT 1\n");
186+
writeFileSync(
187+
join(projectDir, "y.sql"),
188+
"SELECT 2\n",
189+
// Will load in Tracer 3; for now project recipes are silently dropped.
190+
);
191+
const r = loadAllRecipes({ bundledDir, projectDir });
192+
expect(r.map((x) => x.id)).toEqual(["x"]);
193+
});
194+
});

src/application/recipes-loader.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
/**
5+
* One agent-facing follow-up suggested for every row of a recipe's result.
6+
* Recipe authors hand-write this alongside the SQL (predictable: every row gets
7+
* the same template). Ad-hoc SQL never carries actions — recipe-only feature.
8+
*
9+
* `auto_fixable` defaults to `false` when omitted. `description` is human prose
10+
* for the agent to surface; `type` is a stable kebab-case verb the agent can
11+
* key off (`delete-file`, `split-barrel`, `flag-caller`, …).
12+
*/
13+
export interface RecipeAction {
14+
type: string;
15+
auto_fixable?: boolean;
16+
description?: string;
17+
}
18+
19+
/**
20+
* One loaded recipe — the canonical shape the loader returns. Bundled and
21+
* project recipes share this shape; `source` discriminates them. `shadows`
22+
* is true when a project recipe overrides a bundled recipe of the same id
23+
* (see plan §9 Q-E — agents read this at session start to know when a
24+
* recipe behaves differently from the documented bundled version).
25+
*/
26+
export interface LoadedRecipe {
27+
id: string;
28+
sql: string;
29+
description: string | undefined;
30+
body: string | undefined;
31+
actions: RecipeAction[] | undefined;
32+
source: "bundled" | "project";
33+
shadows: boolean;
34+
}
35+
36+
export interface LoadRecipesOpts {
37+
/**
38+
* Absolute path to the directory containing bundled recipe `.sql` files.
39+
* Resolved by the caller via `resolveBundledRecipesDir()` (npm package
40+
* layout — `templates/recipes/` next to `templates/agents/`).
41+
*/
42+
bundledDir: string;
43+
/**
44+
* Absolute path to the project's `.codemap/recipes/` directory, or
45+
* `undefined` if it doesn't exist. Tracer 3 wires this; Tracer 1
46+
* accepts but doesn't read it.
47+
*/
48+
projectDir: string | undefined;
49+
}
50+
51+
/**
52+
* Eager loader — reads every `<id>.sql` from `bundledDir` (and `projectDir`
53+
* once Tracer 3 lands), pairs each with optional `<id>.md`, applies
54+
* load-time validation (non-empty SQL after stripping comments;
55+
* lexical DML/DDL deny-list — Tracer 5), and returns the merged list.
56+
*
57+
* Project recipes win on id collision (`shadows: true` flag; see plan
58+
* §9 Q-E). Per plan §9 Q-B (eager startup load), this is called once
59+
* at module init in `cli/query-recipes.ts`'s shim layer; the result
60+
* is module-cached for the process lifetime.
61+
*/
62+
export function loadAllRecipes(opts: LoadRecipesOpts): LoadedRecipe[] {
63+
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+
70+
return mergeRecipes(bundled, project);
71+
}
72+
73+
/**
74+
* Project recipes win on id collision; matching bundled entries are filtered
75+
* out and the project entry's `shadows` flag is flipped to `true`. Order:
76+
* project first (in id order), then bundled (in id order) — the catalog
77+
* surface stays deterministic per directory listing.
78+
*/
79+
export function mergeRecipes(
80+
bundled: LoadedRecipe[],
81+
project: LoadedRecipe[],
82+
): LoadedRecipe[] {
83+
const projectIds = new Set(project.map((r) => r.id));
84+
const flaggedProject = project.map((r) => ({
85+
...r,
86+
shadows: projectIds.has(r.id) && bundled.some((b) => b.id === r.id),
87+
}));
88+
const filteredBundled = bundled.filter((r) => !projectIds.has(r.id));
89+
return [...flaggedProject, ...filteredBundled].sort((a, b) =>
90+
a.id.localeCompare(b.id),
91+
);
92+
}
93+
94+
/**
95+
* Read every `<id>.sql` from `dir`, pair with optional `<id>.md`. Returns
96+
* `[]` if the directory doesn't exist (project-recipes case in Tracer 3 —
97+
* absence of `.codemap/recipes/` is not an error). Throws if the directory
98+
* exists but a `<id>.sql` fails the load-time validation (Tracer 5 will
99+
* extend this with the DML/DDL lexical check).
100+
*/
101+
export function readRecipesFromDir(
102+
dir: string,
103+
source: "bundled" | "project",
104+
): LoadedRecipe[] {
105+
if (!existsSync(dir)) return [];
106+
const stat = statSync(dir);
107+
if (!stat.isDirectory()) return [];
108+
109+
const entries = readdirSync(dir);
110+
const recipes: LoadedRecipe[] = [];
111+
112+
for (const entry of entries) {
113+
if (!entry.endsWith(".sql")) continue;
114+
const id = entry.slice(0, -".sql".length);
115+
if (id.length === 0) continue;
116+
const sqlPath = join(dir, entry);
117+
const sql = readFileSync(sqlPath, "utf8");
118+
if (isEffectivelyEmpty(sql)) {
119+
throw new Error(
120+
`Recipe "${id}" at ${sqlPath} is empty (no SQL after stripping -- comments and whitespace).`,
121+
);
122+
}
123+
124+
const mdPath = join(dir, `${id}.md`);
125+
const md = existsSync(mdPath) ? readFileSync(mdPath, "utf8") : undefined;
126+
const description = md !== undefined ? firstNonEmptyLine(md) : undefined;
127+
128+
recipes.push({
129+
id,
130+
sql,
131+
description,
132+
body: md,
133+
// Tracer 5 will populate this from YAML frontmatter on `md`.
134+
actions: undefined,
135+
source,
136+
shadows: false,
137+
});
138+
}
139+
140+
return recipes.sort((a, b) => a.id.localeCompare(b.id));
141+
}
142+
143+
/**
144+
* Strip `--` line comments and trailing whitespace; return true if nothing
145+
* meaningful remains. Same shape the load-time DML/DDL check (Tracer 5)
146+
* will extend.
147+
*/
148+
function isEffectivelyEmpty(sql: string): boolean {
149+
const stripped = sql
150+
.split("\n")
151+
.map((line) => {
152+
const commentIdx = line.indexOf("--");
153+
return commentIdx === -1 ? line : line.slice(0, commentIdx);
154+
})
155+
.join("\n")
156+
.trim();
157+
return stripped.length === 0;
158+
}
159+
160+
function firstNonEmptyLine(text: string): string | undefined {
161+
for (const raw of text.split("\n")) {
162+
const trimmed = raw.trim();
163+
if (trimmed.length === 0) continue;
164+
// Strip leading Markdown header markers so "# Fan-out" → "Fan-out".
165+
return trimmed.replace(/^#+\s+/, "");
166+
}
167+
return undefined;
168+
}

0 commit comments

Comments
 (0)