Skip to content

Commit 05901e9

Browse files
committed
feat(recipes): YAML frontmatter actions + load-time DML/DDL deny-list (Tracer 5 of 6)
Closes the Q-D + Q-F open questions from the grill round. Q-D — actions for project-local recipes: - Hand-rolled YAML frontmatter parser in extractFrontmatterAndBody (~30 LOC core, ~50 LOC including helpers). Strict shape: one optional 'actions' list of {type, auto_fixable?, description?} between --- delimiters at the top of <id>.md. Other top-level keys tolerated (forward-compat for future recipe metadata). Unknown action keys silently ignored. Items missing 'type' are filtered out (defensive). - Lifted the 6 bundled recipes' actions (fan-out, fan-in, files-largest, deprecated-symbols, visibility-tags, barrel-files) from BUNDLED_RECIPE_ACTIONS in cli/query-recipes.ts into YAML frontmatter on each templates/recipes/<id>.md. The map is gone — uniform shape for both bundled and project recipes (Q-A's promised 'one storage shape, one loader code path'). Q-F — load-time DML/DDL lexical check: - validateRecipeSql exported from recipes-loader. Strips -- comments, finds first identifier-shaped token, rejects if in deny-list (INSERT/UPDATE/DELETE/DROP/CREATE/ALTER/ATTACH/DETACH/REPLACE/TRUNCATE/VACUUM/PRAGMA). Recipe-aware error message points at --save-baseline as the legitimate path for capturing rows. - Runtime PRAGMA query_only=1 backstop from PR #35 stays unchanged — different jobs: lexical = good UX for common mistakes; backstop = correctness for what slips by. Lessons re-learned (already in .agents/lessons.md): backticks containing colons in line/block comments break Bun's parser; /* */ inside backticks closes the surrounding /** */ JSDoc. Avoided both by replacing problematic backticks with plain quotes / parentheses. Tests: 27 new — 13 for validateRecipeSql, 7 for extractFrontmatterAndBody, 1 integration confirming actions + description both populate from a single .md. Total now 54 pass on the loader + shim test files.
1 parent 0caf6eb commit 05901e9

9 files changed

Lines changed: 436 additions & 85 deletions

File tree

src/application/recipes-loader.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

66
import {
7+
extractFrontmatterAndBody,
78
loadAllRecipes,
89
mergeRecipes,
910
readRecipesFromDir,
11+
validateRecipeSql,
1012
} from "./recipes-loader";
1113
import type { LoadedRecipe } from "./recipes-loader";
1214

@@ -221,3 +223,189 @@ describe("loadAllRecipes — bundled + project composition", () => {
221223
expect(r[0]!.source).toBe("bundled");
222224
});
223225
});
226+
227+
describe("validateRecipeSql — load-time DML/DDL deny-list", () => {
228+
it("accepts SELECT (the common case)", () => {
229+
expect(() =>
230+
validateRecipeSql("ok", "/tmp/ok.sql", "SELECT 1\n"),
231+
).not.toThrow();
232+
});
233+
234+
it("accepts WITH-prefixed CTEs", () => {
235+
expect(() =>
236+
validateRecipeSql(
237+
"cte",
238+
"/tmp/cte.sql",
239+
"WITH x AS (SELECT 1) SELECT * FROM x\n",
240+
),
241+
).not.toThrow();
242+
});
243+
244+
it("rejects DELETE with recipe-aware error", () => {
245+
expect(() =>
246+
validateRecipeSql("bad", "/tmp/bad.sql", "DELETE FROM files\n"),
247+
).toThrow(/recipes must be read-only/);
248+
});
249+
250+
for (const verb of [
251+
"INSERT",
252+
"UPDATE",
253+
"DROP",
254+
"CREATE",
255+
"ALTER",
256+
"ATTACH",
257+
"DETACH",
258+
"REPLACE",
259+
"TRUNCATE",
260+
"VACUUM",
261+
"PRAGMA",
262+
]) {
263+
it(`rejects ${verb} at load time`, () => {
264+
expect(() =>
265+
validateRecipeSql(
266+
"bad",
267+
"/tmp/bad.sql",
268+
`${verb} something arbitrary\n`,
269+
),
270+
).toThrow(/read-only/);
271+
});
272+
}
273+
274+
it("ignores leading -- comments before the keyword", () => {
275+
expect(() =>
276+
validateRecipeSql(
277+
"ok",
278+
"/tmp/ok.sql",
279+
"-- doc line\n-- another doc\nSELECT 1\n",
280+
),
281+
).not.toThrow();
282+
});
283+
284+
it("rejects lowercase deny-list keywords (case-insensitive)", () => {
285+
expect(() =>
286+
validateRecipeSql("bad", "/tmp/bad.sql", "drop table x\n"),
287+
).toThrow(/read-only/);
288+
});
289+
});
290+
291+
describe("extractFrontmatterAndBody — YAML actions parser", () => {
292+
it("returns body as full text when no frontmatter delimiter present", () => {
293+
const md = "Just some plain markdown.\n";
294+
const r = extractFrontmatterAndBody(md);
295+
expect(r.actions).toBeUndefined();
296+
expect(r.body).toBe(md);
297+
});
298+
299+
it("parses a single action with type only", () => {
300+
const md = `---
301+
actions:
302+
- type: review-coupling
303+
---
304+
Body line one
305+
Body line two
306+
`;
307+
const r = extractFrontmatterAndBody(md);
308+
expect(r.actions).toEqual([{ type: "review-coupling" }]);
309+
expect(r.body.startsWith("Body line one")).toBe(true);
310+
});
311+
312+
it("parses action with type + description (double-quoted)", () => {
313+
const md = `---
314+
actions:
315+
- type: split-barrel
316+
description: "Confirm intent before splitting."
317+
---
318+
body
319+
`;
320+
const r = extractFrontmatterAndBody(md);
321+
expect(r.actions).toEqual([
322+
{ type: "split-barrel", description: "Confirm intent before splitting." },
323+
]);
324+
});
325+
326+
it("parses action with auto_fixable: true (boolean scalar)", () => {
327+
const md = `---
328+
actions:
329+
- type: delete-file
330+
auto_fixable: true
331+
description: bare unquoted text is fine
332+
---
333+
body
334+
`;
335+
const r = extractFrontmatterAndBody(md);
336+
expect(r.actions).toEqual([
337+
{
338+
type: "delete-file",
339+
auto_fixable: true,
340+
description: "bare unquoted text is fine",
341+
},
342+
]);
343+
});
344+
345+
it("parses multiple action items", () => {
346+
const md = `---
347+
actions:
348+
- type: a
349+
- type: b
350+
description: second
351+
---
352+
body
353+
`;
354+
const r = extractFrontmatterAndBody(md);
355+
expect(r.actions).toEqual([
356+
{ type: "a" },
357+
{ type: "b", description: "second" },
358+
]);
359+
});
360+
361+
it("returns undefined actions when no actions key in frontmatter", () => {
362+
const md = `---
363+
some_other_key: value
364+
---
365+
body
366+
`;
367+
const r = extractFrontmatterAndBody(md);
368+
expect(r.actions).toBeUndefined();
369+
expect(r.body.startsWith("body")).toBe(true);
370+
});
371+
372+
it("treats malformed frontmatter (no closing ---) as no frontmatter", () => {
373+
const md = `---
374+
actions:
375+
- type: foo
376+
this never closes
377+
`;
378+
const r = extractFrontmatterAndBody(md);
379+
expect(r.actions).toBeUndefined();
380+
expect(r.body).toBe(md);
381+
});
382+
});
383+
384+
describe("readRecipesFromDir — frontmatter integration", () => {
385+
it("populates actions from sibling .md frontmatter", () => {
386+
const dir = makeRecipeDir("with-frontmatter");
387+
writeFileSync(join(dir, "fan-out.sql"), "SELECT 1\n");
388+
writeFileSync(
389+
join(dir, "fan-out.md"),
390+
`---
391+
actions:
392+
- type: review-coupling
393+
description: "High fan-out usually means orchestrator role."
394+
---
395+
396+
Top 10 files by dependency fan-out (edge count)
397+
`,
398+
);
399+
const r = readRecipesFromDir(dir, "bundled");
400+
expect(r).toHaveLength(1);
401+
expect(r[0]!.actions).toEqual([
402+
{
403+
type: "review-coupling",
404+
description: "High fan-out usually means orchestrator role.",
405+
},
406+
]);
407+
expect(r[0]!.description).toBe(
408+
"Top 10 files by dependency fan-out (edge count)",
409+
);
410+
});
411+
});

0 commit comments

Comments
 (0)