Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/boolean-sqlite-bind.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions scripts/agent-eval/probe-tokens.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand All @@ -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") +
Expand Down
4 changes: 2 additions & 2 deletions scripts/agent-eval/run-probes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[];
Expand Down
4 changes: 2 additions & 2 deletions scripts/query-golden/resolve-golden-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions src/application/apply-command-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});
11 changes: 6 additions & 5 deletions src/application/query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 38 additions & 4 deletions src/application/recipe-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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",
Expand All @@ -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", () => {
Expand Down
54 changes: 36 additions & 18 deletions src/application/recipe-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand All @@ -15,20 +15,28 @@ 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<string, RecipeParamValue>;

/** 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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -129,11 +138,19 @@ export function resolveRecipeParams(opts: {

function coerceParamValue(
param: RecipeParam,
raw: RecipeParamValue,
raw: RecipeParamValue | null,
recipeId: string,
):
| { ok: true; value: Exclude<RecipeParamValue, null> }
| { ok: true; value: Exclude<ResolvedRecipeParamValue, null> }
| 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) };
}
Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/application/tool-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1357,7 +1360,7 @@ function runFormattedQuery(args: {
recipeId: string | undefined;
recipeActions: ReadonlyArray<unknown> | undefined;
changedFiles: Set<string> | undefined;
bindValues?: RecipeParamValue[] | undefined;
bindValues?: ResolvedRecipeParamValue[] | undefined;
format:
| "sarif"
| "annotations"
Expand Down
2 changes: 1 addition & 1 deletion src/cli/cmd-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});

Expand Down
12 changes: 6 additions & 6 deletions src/cli/cmd-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ import {
resolveRecipeParams,
} from "../application/recipe-params";
import type {
RecipeParamValue,
RecipeParamValues,
ResolvedRecipeParamValue,
} from "../application/recipe-params";
import {
enrichWithRecency,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1112,7 +1112,7 @@ function printFormattedQuery(
format: Exclude<OutputFormat, "text" | "json">;
recipeId: string | undefined;
changedFiles: Set<string> | undefined;
bindValues: RecipeParamValue[] | undefined;
bindValues: ResolvedRecipeParamValue[] | undefined;
/** `--ci`: suppress no-locatable-rows warning + exit 1 on `rows.length > 0`. */
ci?: boolean;
badgeStyle: BadgeStyle;
Expand Down Expand Up @@ -1215,7 +1215,7 @@ function runGroupedQuery(opts: {
groupBy: GroupByMode;
changedFiles: Set<string> | undefined;
recipeActions: ReadonlyArray<unknown> | undefined;
bindValues: RecipeParamValue[] | undefined;
bindValues: ResolvedRecipeParamValue[] | undefined;
root: string;
}) {
let bucketize: Bucketizer;
Expand Down Expand Up @@ -1308,7 +1308,7 @@ function runSaveBaseline(opts: {
recipeId: string | undefined;
baselineName: string;
changedFiles: Set<string> | undefined;
bindValues: RecipeParamValue[] | undefined;
bindValues: ResolvedRecipeParamValue[] | undefined;
}) {
let rows: unknown[];
try {
Expand Down Expand Up @@ -1368,7 +1368,7 @@ function runBaselineDiff(opts: {
baselineName: string;
changedFiles: Set<string> | undefined;
recipeActions: ReadonlyArray<unknown> | undefined;
bindValues: RecipeParamValue[] | undefined;
bindValues: ResolvedRecipeParamValue[] | undefined;
}) {
const result = compareQueryBaseline({
baselineName: opts.baselineName,
Expand Down