diff --git a/.changeset/boolean-sqlite-bind.md b/.changeset/boolean-sqlite-bind.md new file mode 100644 index 00000000..84b16602 --- /dev/null +++ b/.changeset/boolean-sqlite-bind.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Fix recipes with `boolean` params failing on Node when run with defaults. Boolean params now bind as `0` / `1`, so recipes like `churn-complexity-hotspots` and `stale-imports` work again. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42c6aef9..5644d7f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,14 @@ jobs: timeout 45 node dist/index.mjs --full node dist/index.mjs query "SELECT COUNT(*) AS files FROM files" + # Boolean recipe params must bind as 0/1 β€” better-sqlite3 rejects JS booleans. + # Bun dogfood can mask this; empty rows are fine (bind must not throw). + - name: Node smoke (boolean recipe params bind) + run: | + set -euo pipefail + export CODEMAP_ROOT="$GITHUB_WORKSPACE/fixtures/minimal" + node dist/index.mjs query --json --recipe churn-complexity-hotspots + check-pack: name: πŸ“¦ Pack validation needs: skip-ci diff --git a/scripts/agent-eval/probe-tokens.ts b/scripts/agent-eval/probe-tokens.ts index 523299b2..dc009ea6 100644 --- a/scripts/agent-eval/probe-tokens.ts +++ b/scripts/agent-eval/probe-tokens.ts @@ -1,4 +1,4 @@ -import type { RecipeParamValue } from "../../src/application/recipe-params"; +import type { ResolvedRecipeParamValue } from "../../src/application/recipe-params"; import { estimateTokens, jsonCharLength } from "./metrics"; /** Probe-mode token budget: prompt + payload chars, then chars/4 (plan L.4). */ @@ -12,7 +12,7 @@ export function estimateProbeTokens( export function mcpOnPayloadChars( sql: string, rows: unknown[], - bindValues: RecipeParamValue[] = [], + bindValues: ResolvedRecipeParamValue[] = [], ): number { return ( Buffer.byteLength(sql, "utf-8") + diff --git a/scripts/agent-eval/run-probes.ts b/scripts/agent-eval/run-probes.ts index d06c9004..0277c46c 100644 --- a/scripts/agent-eval/run-probes.ts +++ b/scripts/agent-eval/run-probes.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { createCodemap } from "../../src/api"; import { queryRows } from "../../src/application/index-engine"; -import type { RecipeParamValue } from "../../src/application/recipe-params"; +import type { ResolvedRecipeParamValue } from "../../src/application/recipe-params"; import { resolveCodemapConfig } from "../../src/config"; import { resolveGoldenQuery } from "../query-golden/resolve-golden-query"; import { runGoldenSetup } from "../query-golden/run-setup"; @@ -154,7 +154,7 @@ function parseArgs(argv: string[]) { function runMcpOnArm( prompt: string, sql: string, - bindValues: RecipeParamValue[], + bindValues: ResolvedRecipeParamValue[], ): ArmRunMetrics { const t0 = performance.now(); const rows = queryRows(sql, bindValues) as unknown[]; diff --git a/scripts/query-golden/resolve-golden-query.ts b/scripts/query-golden/resolve-golden-query.ts index 5215c513..733d41d2 100644 --- a/scripts/query-golden/resolve-golden-query.ts +++ b/scripts/query-golden/resolve-golden-query.ts @@ -3,12 +3,12 @@ import { getQueryRecipeSql, } from "../../src/application/query-recipes"; import { resolveRecipeParams } from "../../src/application/recipe-params"; -import type { RecipeParamValue } from "../../src/application/recipe-params"; +import type { ResolvedRecipeParamValue } from "../../src/application/recipe-params"; import type { GoldenScenario } from "./schema"; export function resolveGoldenQuery(s: GoldenScenario): { sql: string; - bindValues: RecipeParamValue[]; + bindValues: ResolvedRecipeParamValue[]; } { if (s.sql !== undefined) { if (s.params !== undefined) { diff --git a/src/application/apply-command-template.test.ts b/src/application/apply-command-template.test.ts index 7962cc04..e3b1f019 100644 --- a/src/application/apply-command-template.test.ts +++ b/src/application/apply-command-template.test.ts @@ -60,10 +60,11 @@ describe("renderRecipeActionCommands", () => { "codemap apply stale-imports --params in_file={{in_file}},include_type_only={{include_type_only}} --dry-run --force", }, ], - { in_file: "src/widget", include_type_only: true }, + // Resolved boolean params are 0/1 (see resolveRecipeParams). + { in_file: "src/widget", include_type_only: 1 }, ); expect(out?.[0]?.command).toBe( - "codemap apply stale-imports --params in_file=src/widget,include_type_only=true --dry-run --force", + "codemap apply stale-imports --params in_file=src/widget,include_type_only=1 --dry-run --force", ); }); }); diff --git a/src/application/query-engine.ts b/src/application/query-engine.ts index b1272563..c01831bb 100644 --- a/src/application/query-engine.ts +++ b/src/application/query-engine.ts @@ -11,12 +11,13 @@ import type { Bucketizer, GroupByMode } from "../group-by"; import type { CodemapDatabase } from "../sqlite-db"; /** - * SQLite bind value β€” the union accepted by `db.query(sql).all(...values)`. - * Kept here at the DB boundary so `executeQuery` doesn't depend on any - * recipe-layer type. Recipe coercion lives in `application/recipe-params.ts` - * and produces values assignable to this union. + * SQLite bind value β€” the union accepted by `db.query(sql).all(...values)` + * (better-sqlite3 / bun:sqlite). Kept here at the DB boundary so + * `executeQuery` doesn't depend on any recipe-layer type. Recipe coercion + * lives in `application/recipe-params.ts` and produces values assignable to + * this union (`type: "boolean"` params become `0` / `1`, never JS boolean). */ -export type QueryBindValue = string | number | bigint | boolean | null; +export type QueryBindValue = string | number | bigint | null; /** * Pure, transport-agnostic query execution. Mirrors the layering of diff --git a/src/application/recipe-params.test.ts b/src/application/recipe-params.test.ts index 466a479e..55618ca1 100644 --- a/src/application/recipe-params.test.ts +++ b/src/application/recipe-params.test.ts @@ -47,7 +47,7 @@ describe("resolveRecipeParams", () => { include_tests: "false", }, }); - expect(r).toEqual({ ok: true, values: ["function", 42, false] }); + expect(r).toEqual({ ok: true, values: ["function", 42, 0] }); }); it("uses defaults for omitted optional params", () => { @@ -56,7 +56,30 @@ describe("resolveRecipeParams", () => { declared, provided: { kind: "function" }, }); - expect(r).toEqual({ ok: true, values: ["function", 80, true] }); + expect(r).toEqual({ ok: true, values: ["function", 80, 1] }); + }); + + it("never puts JS booleans in bind values", () => { + const r = resolveRecipeParams({ + recipeId: "example", + declared, + provided: { + kind: "function", + include_tests: false, + }, + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + for (const v of r.values) { + expect(typeof v === "boolean").toBe(false); + expect( + typeof v === "string" || + typeof v === "number" || + typeof v === "bigint" || + v === null, + ).toBe(true); + } + expect(r.values).toEqual(["function", 80, 0]); }); it("binds omitted optional params without defaults as null", () => { @@ -71,6 +94,17 @@ describe("resolveRecipeParams", () => { expect(r).toEqual({ ok: true, values: ["x", null] }); }); + it("rejects explicit null from callers (omit key instead)", () => { + const r = resolveRecipeParams({ + recipeId: "example", + declared: [{ name: "min_coverage", type: "number", required: true }], + // Cast: production maps are typed without null; runtime JSON can still send it. + provided: { min_coverage: null as unknown as number }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("must not be null"); + }); + it("rejects missing required params", () => { const r = resolveRecipeParams({ recipeId: "example", @@ -97,14 +131,14 @@ describe("resolveRecipeParams", () => { declared, provided: { kind: "function", include_tests: 1 }, }); - expect(truthy).toEqual({ ok: true, values: ["function", 80, true] }); + expect(truthy).toEqual({ ok: true, values: ["function", 80, 1] }); const falsy = resolveRecipeParams({ recipeId: "example", declared, provided: { kind: "function", include_tests: 0 }, }); - expect(falsy).toEqual({ ok: true, values: ["function", 80, false] }); + expect(falsy).toEqual({ ok: true, values: ["function", 80, 0] }); }); it("rejects malformed numbers and booleans", () => { diff --git a/src/application/recipe-params.ts b/src/application/recipe-params.ts index 88154ceb..2a18dea9 100644 --- a/src/application/recipe-params.ts +++ b/src/application/recipe-params.ts @@ -3,7 +3,7 @@ import type { RecipeParam } from "./recipes-loader"; /** Map positional bind values back to param names for action command templates. */ export function recipeParamValuesFromResolved( declared: RecipeParam[] | undefined, - values: RecipeParamValue[], + values: ResolvedRecipeParamValue[], ): RecipeParamValues { const out: RecipeParamValues = {}; for (let i = 0; i < (declared ?? []).length; i++) { @@ -15,12 +15,20 @@ export function recipeParamValuesFromResolved( } /** - * One bound parameter value. `null` is internal-only β€” callers may not pass - * `null` directly; the resolver assigns it for declared optional params that - * the caller omitted, so positional `?` placeholders stay aligned with the - * declaration order in the recipe. + * One parameter value from a caller. May include JS `boolean` for + * `type: "boolean"` params; {@link resolveRecipeParams} coerces those to + * SQLite-safe `1` / `0` in {@link ResolvedRecipeParamValue}. Callers must + * not pass `null` β€” omit the key for optional params; the resolver assigns + * `null` on the resolved list so positional `?` placeholders stay aligned. */ -export type RecipeParamValue = string | number | boolean | null; +export type RecipeParamValue = string | number | boolean; + +/** + * Bind-ready value after {@link resolveRecipeParams} β€” never JS `boolean` + * (better-sqlite3 rejects them). `null` is resolver-only for omitted optional + * params. Assignable to `QueryBindValue`. + */ +export type ResolvedRecipeParamValue = string | number | null; /** Loose `key: value` map of params provided to a recipe by the caller. */ export type RecipeParamValues = Record; @@ -28,7 +36,7 @@ export type RecipeParamValues = Record; /** Successful resolution; `values` are positional in declaration order. */ export interface ResolveRecipeParamsOk { ok: true; - values: RecipeParamValue[]; + values: ResolvedRecipeParamValue[]; } /** Resolution failure; `error` carries a single human-readable message. */ @@ -68,8 +76,9 @@ export function mergeParams( /** * Validate `provided` against `declared` and produce positional bind values * in declaration order. Strict on missing required, unknown keys, and type - * mismatches; coerces `string | number` into the declared `number` / - * `boolean` types where the value is unambiguous. + * mismatches; coerces `string | number` into the declared `number` type and + * `boolean` / `true`/`false` / `1`/`0` into INTEGER `1` / `0` (better-sqlite3 + * rejects JS booleans at bind time; recipe SQL compares with `= 0` / `!= 0`). */ export function resolveRecipeParams(opts: { recipeId: string; @@ -97,7 +106,7 @@ export function resolveRecipeParams(opts: { } } - const values: RecipeParamValue[] = []; + const values: ResolvedRecipeParamValue[] = []; for (const param of declared) { const raw = provided[param.name]; if (raw === undefined) { @@ -129,11 +138,19 @@ export function resolveRecipeParams(opts: { function coerceParamValue( param: RecipeParam, - raw: RecipeParamValue, + raw: RecipeParamValue | null, recipeId: string, ): - | { ok: true; value: Exclude } + | { ok: true; value: Exclude } | ResolveRecipeParamsError { + // Runtime guard β€” `RecipeParamValue` excludes null, but `provided` maps and + // JSON/MCP edges can still deliver it; Number(null)β†’0 / String(null)β†’"null". + if (raw === null) { + return { + ok: false, + error: `${prefix(recipeId)} --params ${param.name} must not be null (omit the key for optional params).`, + }; + } if (param.type === "string") { return { ok: true, value: String(raw) }; } @@ -153,15 +170,16 @@ function coerceParamValue( } return { ok: true, value: n }; } - if (typeof raw === "boolean") return { ok: true, value: raw }; - // Accept numeric `1`/`0` as well as their string forms β€” MCP / HTTP callers - // hit this path because `query_recipe.params` accepts `z.number()` and the - // CLI / HTTP layers don't pre-coerce numeric booleans. + // Bind as INTEGER 0/1 β€” better-sqlite3 rejects JS booleans; recipe SQL + // already uses `= 0` / `!= 0`. Accept numeric `1`/`0` and their string + // forms β€” MCP / HTTP callers hit this path because `query_recipe.params` + // accepts `z.number()` and the CLI / HTTP layers don't pre-coerce. + if (typeof raw === "boolean") return { ok: true, value: raw ? 1 : 0 }; if (raw === "true" || raw === "1" || raw === 1) { - return { ok: true, value: true }; + return { ok: true, value: 1 }; } if (raw === "false" || raw === "0" || raw === 0) { - return { ok: true, value: false }; + return { ok: true, value: 0 }; } return { ok: false, diff --git a/src/application/tool-handlers.ts b/src/application/tool-handlers.ts index 30f74e46..0f584cf1 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -80,7 +80,10 @@ import { getQueryRecipeSql, } from "./query-recipes"; import { resolveRecipeParams } from "./recipe-params"; -import type { RecipeParamValue, RecipeParamValues } from "./recipe-params"; +import type { + RecipeParamValues, + ResolvedRecipeParamValue, +} from "./recipe-params"; import { tryRecordRecipeRun } from "./recipe-recency"; import { runCodemapIndex } from "./run-index"; import { buildShowResult, buildSnippetResult } from "./show-engine"; @@ -1357,7 +1360,7 @@ function runFormattedQuery(args: { recipeId: string | undefined; recipeActions: ReadonlyArray | undefined; changedFiles: Set | undefined; - bindValues?: RecipeParamValue[] | undefined; + bindValues?: ResolvedRecipeParamValue[] | undefined; format: | "sarif" | "annotations" diff --git a/src/cli/cmd-query.test.ts b/src/cli/cmd-query.test.ts index fa2f0793..9538ea37 100644 --- a/src/cli/cmd-query.test.ts +++ b/src/cli/cmd-query.test.ts @@ -1149,7 +1149,7 @@ describe("getQueryRecipeActionsRendered β€” auditβ†’apply pairs (C.6)", () => { in_file: "src/widget", }); expect(actions?.[0]?.command).toBe( - "codemap apply stale-imports --params in_file=src/widget,include_type_only=false --dry-run --force", + "codemap apply stale-imports --params in_file=src/widget,include_type_only=0 --dry-run --force", ); }); diff --git a/src/cli/cmd-query.ts b/src/cli/cmd-query.ts index c1796106..7b460477 100644 --- a/src/cli/cmd-query.ts +++ b/src/cli/cmd-query.ts @@ -34,8 +34,8 @@ import { resolveRecipeParams, } from "../application/recipe-params"; import type { - RecipeParamValue, RecipeParamValues, + ResolvedRecipeParamValue, } from "../application/recipe-params"; import { enrichWithRecency, @@ -1011,7 +1011,7 @@ function resolveRecipeBindValues(opts: { recipeId: string | undefined; params: RecipeParamValues | undefined; json: boolean; -}): { values: RecipeParamValue[] } | { error: true } { +}): { values: ResolvedRecipeParamValue[] } | { error: true } { if (opts.recipeId === undefined) return { values: [] }; const resolved = resolveRecipeParams({ recipeId: opts.recipeId, @@ -1112,7 +1112,7 @@ function printFormattedQuery( format: Exclude; recipeId: string | undefined; changedFiles: Set | undefined; - bindValues: RecipeParamValue[] | undefined; + bindValues: ResolvedRecipeParamValue[] | undefined; /** `--ci`: suppress no-locatable-rows warning + exit 1 on `rows.length > 0`. */ ci?: boolean; badgeStyle: BadgeStyle; @@ -1215,7 +1215,7 @@ function runGroupedQuery(opts: { groupBy: GroupByMode; changedFiles: Set | undefined; recipeActions: ReadonlyArray | undefined; - bindValues: RecipeParamValue[] | undefined; + bindValues: ResolvedRecipeParamValue[] | undefined; root: string; }) { let bucketize: Bucketizer; @@ -1308,7 +1308,7 @@ function runSaveBaseline(opts: { recipeId: string | undefined; baselineName: string; changedFiles: Set | undefined; - bindValues: RecipeParamValue[] | undefined; + bindValues: ResolvedRecipeParamValue[] | undefined; }) { let rows: unknown[]; try { @@ -1368,7 +1368,7 @@ function runBaselineDiff(opts: { baselineName: string; changedFiles: Set | undefined; recipeActions: ReadonlyArray | undefined; - bindValues: RecipeParamValue[] | undefined; + bindValues: ResolvedRecipeParamValue[] | undefined; }) { const result = compareQueryBaseline({ baselineName: opts.baselineName,