@checklabs/core 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@checklabs/core",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "description": "CheckAI core: assertions, scored LLM judge, adapters, runner, comparison and reporters.",
6
+ "license": "MIT",
7
+ "author": "MaxDanchenko",
8
+ "homepage": "https://github.com/MaxDanchenko/check-ai#readme",
9
+ "bugs": "https://github.com/MaxDanchenko/check-ai/issues",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/MaxDanchenko/check-ai.git",
13
+ "directory": "packages/checkai-core"
14
+ },
15
+ "keywords": ["ai", "testing", "regression-testing", "agents", "llm", "evals"],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "main": "src/index.ts",
20
+ "types": "src/index.ts",
21
+ "exports": {
22
+ ".": "./src/index.ts"
23
+ },
24
+ "files": ["src", "dist"],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json --outDir dist"
30
+ }
31
+ }
@@ -0,0 +1,136 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { basename, dirname } from "node:path";
3
+ import type { AgentAdapter, AgentResponse, AgentSource } from "../types";
4
+ import { estimateUsage, estimateCost } from "../pricing";
5
+
6
+ /** Fill in usage + cost if the adapter didn't provide them. */
7
+ export function finalizeResponse(input: string, res: AgentResponse): AgentResponse {
8
+ const usage = res.usage ?? estimateUsage(input, res.output);
9
+ const costUsd = res.costUsd ?? estimateCost(usage, res.model);
10
+ return { ...res, usage, costUsd };
11
+ }
12
+
13
+ /**
14
+ * Wrap a plain `runAgent(input)` function as an adapter. This is the simplest
15
+ * way to connect a local agent — your function just returns an AgentResponse.
16
+ */
17
+ export function functionAdapter(
18
+ run: (input: string) => Promise<AgentResponse>,
19
+ opts: { name?: string; model?: string } = {}
20
+ ): AgentAdapter {
21
+ let model = opts.model ?? "";
22
+ return {
23
+ name: opts.name ?? "agent",
24
+ get model() {
25
+ return model;
26
+ },
27
+ async run(input: string): Promise<AgentResponse> {
28
+ const res = finalizeResponse(input, await run(input));
29
+ if (!model && res.model) model = res.model;
30
+ return res;
31
+ },
32
+ };
33
+ }
34
+
35
+ export interface HttpAdapterOptions {
36
+ url: string;
37
+ name?: string;
38
+ model?: string;
39
+ method?: string;
40
+ headers?: Record<string, string>;
41
+ /** Build the request body from the user input. Default: `{ input }`. */
42
+ body?: (input: string) => unknown;
43
+ /** Map the JSON response to an AgentResponse. Default tries common fields. */
44
+ map?: (json: any, input: string) => Partial<AgentResponse> & { output: string };
45
+ }
46
+
47
+ function defaultMap(json: any): Partial<AgentResponse> & { output: string } {
48
+ const output =
49
+ json?.output ?? json?.text ?? json?.reply ?? json?.message ?? json?.content ?? "";
50
+ return {
51
+ output: String(output),
52
+ toolsUsed: json?.toolsUsed ?? json?.tools ?? [],
53
+ model: json?.model,
54
+ usage: json?.usage,
55
+ };
56
+ }
57
+
58
+ /** Connect an agent exposed over an HTTP endpoint. */
59
+ export function httpAdapter(opts: HttpAdapterOptions): AgentAdapter {
60
+ let model = opts.model ?? "";
61
+ return {
62
+ name: opts.name ?? "http-agent",
63
+ get model() {
64
+ return model;
65
+ },
66
+ async run(input: string): Promise<AgentResponse> {
67
+ const start = Date.now();
68
+ const resp = await fetch(opts.url, {
69
+ method: opts.method ?? "POST",
70
+ headers: { "content-type": "application/json", ...(opts.headers ?? {}) },
71
+ body: JSON.stringify(opts.body ? opts.body(input) : { input }),
72
+ });
73
+ if (!resp.ok) {
74
+ throw new Error(`HTTP ${resp.status} ${resp.statusText} from ${opts.url}`);
75
+ }
76
+ const json = await resp.json();
77
+ const mapped = opts.map ? opts.map(json, input) : defaultMap(json);
78
+ const res = finalizeResponse(input, {
79
+ output: mapped.output,
80
+ toolsUsed: mapped.toolsUsed ?? [],
81
+ latencyMs: mapped.latencyMs ?? Date.now() - start,
82
+ model: mapped.model ?? opts.model ?? "http",
83
+ usage: mapped.usage,
84
+ costUsd: mapped.costUsd,
85
+ raw: json,
86
+ });
87
+ if (!model && res.model) model = res.model;
88
+ return res;
89
+ },
90
+ };
91
+ }
92
+
93
+ function deriveName(agentPath: string, fallback: string): string {
94
+ const parts = agentPath.split(/[\\/]/);
95
+ const srcIdx = parts.lastIndexOf("src");
96
+ if (srcIdx > 0) return parts[srcIdx - 1];
97
+ return basename(dirname(agentPath)) || fallback;
98
+ }
99
+
100
+ function isAdapter(value: unknown): value is AgentAdapter {
101
+ return (
102
+ typeof value === "object" &&
103
+ value !== null &&
104
+ typeof (value as AgentAdapter).run === "function"
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Resolve an {@link AgentSource} (a module path or an inline adapter) into an
110
+ * {@link AgentAdapter}. A module may export an `adapter`/`default` adapter, or a
111
+ * `runAgent(input)` function (optionally with `name`/`model` consts).
112
+ */
113
+ export async function loadAgentSource(
114
+ source: AgentSource,
115
+ fallbackName: string
116
+ ): Promise<AgentAdapter> {
117
+ if (isAdapter(source)) return source;
118
+ if (typeof source !== "string") {
119
+ throw new Error("Agent source must be a module path or an AgentAdapter object.");
120
+ }
121
+
122
+ const mod = await import(pathToFileURL(source).href);
123
+ const exported = mod.adapter ?? mod.default ?? mod.agent;
124
+ if (isAdapter(exported)) return exported;
125
+
126
+ const runAgent = mod.runAgent ?? (typeof exported === "function" ? exported : undefined);
127
+ if (typeof runAgent !== "function") {
128
+ throw new Error(
129
+ `Agent module "${source}" must export runAgent(input) or an AgentAdapter.`
130
+ );
131
+ }
132
+ return functionAdapter(runAgent, {
133
+ name: mod.name ?? deriveName(source, fallbackName),
134
+ model: mod.model ?? "",
135
+ });
136
+ }
@@ -0,0 +1,218 @@
1
+ import type { AgentResponse, AssertionResult } from "../types";
2
+ import { judge, getJudgeThreshold } from "../judge/index";
3
+
4
+ /**
5
+ * Assertion library — the `expect(result).toX()` surface.
6
+ *
7
+ * Synchronous matchers throw immediately on failure (Jest-style). The async
8
+ * `toSatisfyBehavior` returns a Promise the test author awaits. Every matcher
9
+ * supports `.not`. Each attempted assertion is recorded into the active sink so
10
+ * reports can show exactly what was checked.
11
+ */
12
+
13
+ export class CheckAIAssertionError extends Error {
14
+ readonly result: AssertionResult;
15
+ constructor(result: AssertionResult) {
16
+ super(
17
+ `expect(...).${result.matcher}()\n` +
18
+ ` Expected: ${result.expected}\n` +
19
+ ` Actual: ${result.actual}`
20
+ );
21
+ this.name = "CheckAIAssertionError";
22
+ this.result = result;
23
+ }
24
+ }
25
+
26
+ // Active assertion sink, set by the runner around each test.
27
+ let sink: AssertionResult[] | null = null;
28
+ export function setAssertionSink(s: AssertionResult[] | null): void {
29
+ sink = s;
30
+ }
31
+
32
+ // Tracks in-flight async (judge) assertions so the runner can await them even
33
+ // when a test author forgets to `await` toSatisfyBehavior.
34
+ let pendingSink: Promise<unknown>[] | null = null;
35
+ export function setPendingSink(s: Promise<unknown>[] | null): void {
36
+ pendingSink = s;
37
+ }
38
+
39
+ const POLITE = /(please|thank|sorry|apolog|happy to|glad to|of course|certainly|i understand|i'd be happy|appreciate|i can help|anything else (i can )?help|no problem|my pleasure)/i;
40
+ const RUDE = /(stupid|idiot|shut up|whatever|not my problem|deal with it|that'?s your fault|calm down|get over it)/i;
41
+ const APPROVES_REFUND = /(approv\w*[^.]{0,40}refund|refund[^.]{0,40}approv\w*|issued your refund|processed your refund|granted your refund|refund (has been|was) (issued|processed|granted))/i;
42
+ const ESCALATION_WORDS = /(escalat|senior (support )?specialist|connect you (with|to) (a )?(human|specialist|agent|team)|transfer(ring)? you|hand(ing)? (this|it|you) (off|over)|a (human|team member|specialist) will (reach|get|contact))/i;
43
+ const ASK_INDICATORS = /(\?|could you|can you|would you|please (provide|share|confirm|tell|let me know)|what(?:'s| is) your|may i (have|ask)|i(?:'ll| will) need|so i can (look|pull|find))/i;
44
+ const ORDER_REF = /#\s*\d{3,}|order\s*#?\s*\d{3,}|order (number|id|#)/i;
45
+ const POLICY_REF = /policy|terms|return window|30[- ]?day|eligib|guideline|per our|warranty|coverage/i;
46
+
47
+ export interface SatisfyOptions {
48
+ /** Minimum judge score (0..1) required to pass. Defaults to config threshold. */
49
+ threshold?: number;
50
+ }
51
+
52
+ class Expectation {
53
+ constructor(
54
+ private readonly result: AgentResponse,
55
+ private readonly negated: boolean = false
56
+ ) {}
57
+
58
+ /** Negate the next matcher. */
59
+ get not(): Expectation {
60
+ return new Expectation(this.result, !this.negated);
61
+ }
62
+
63
+ private snippet(): string {
64
+ const o = this.result.output.replace(/\s+/g, " ").trim();
65
+ return o.length > 140 ? `"${o.slice(0, 137)}..."` : `"${o}"`;
66
+ }
67
+
68
+ private record(
69
+ rawPass: boolean,
70
+ matcher: string,
71
+ expected: string,
72
+ actual: string,
73
+ extra?: { score?: number; threshold?: number }
74
+ ): this {
75
+ const pass = this.negated ? !rawPass : rawPass;
76
+ const entry: AssertionResult = {
77
+ matcher: (this.negated ? "not." : "") + matcher,
78
+ negated: this.negated,
79
+ pass,
80
+ expected: this.negated ? `NOT — ${expected}` : expected,
81
+ actual,
82
+ score: extra?.score,
83
+ threshold: extra?.threshold,
84
+ };
85
+ sink?.push(entry);
86
+ if (!pass) throw new CheckAIAssertionError(entry);
87
+ return this;
88
+ }
89
+
90
+ // --- text + tools --------------------------------------------------------
91
+
92
+ toContainText(text: string): this {
93
+ const pass = this.result.output.toLowerCase().includes(text.toLowerCase());
94
+ return this.record(
95
+ pass,
96
+ "toContainText",
97
+ `output contains "${text}"`,
98
+ pass ? `found "${text}"` : `not found in ${this.snippet()}`
99
+ );
100
+ }
101
+
102
+ toUseTool(name: string): this {
103
+ const pass = this.result.toolsUsed.includes(name);
104
+ return this.record(
105
+ pass,
106
+ "toUseTool",
107
+ `agent uses tool "${name}"`,
108
+ `toolsUsed = [${this.result.toolsUsed.join(", ")}]`
109
+ );
110
+ }
111
+
112
+ // --- behavior ------------------------------------------------------------
113
+
114
+ toAskFor(thing: string): this {
115
+ const out = this.result.output;
116
+ const mentions = out.toLowerCase().includes(thing.toLowerCase());
117
+ const asks = ASK_INDICATORS.test(out);
118
+ const pass = mentions && asks;
119
+ return this.record(
120
+ pass,
121
+ "toAskFor",
122
+ `agent asks the user for "${thing}"`,
123
+ !mentions
124
+ ? `never mentions "${thing}" — ${this.snippet()}`
125
+ : !asks
126
+ ? `mentions "${thing}" but does not request it — ${this.snippet()}`
127
+ : `asks for "${thing}"`
128
+ );
129
+ }
130
+
131
+ toEscalate(): this {
132
+ const usedTool = this.result.toolsUsed.includes("escalateToHuman");
133
+ const says = ESCALATION_WORDS.test(this.result.output);
134
+ return this.record(
135
+ usedTool || says,
136
+ "toEscalate",
137
+ "agent escalates to a human",
138
+ `escalateToHuman ${usedTool ? "called" : "not called"}; wording ${says ? "present" : "absent"}`
139
+ );
140
+ }
141
+
142
+ toBePolite(): this {
143
+ const polite = POLITE.test(this.result.output);
144
+ const rude = RUDE.test(this.result.output);
145
+ return this.record(
146
+ polite && !rude,
147
+ "toBePolite",
148
+ "reply is polite and professional",
149
+ rude
150
+ ? `contains discourteous language — ${this.snippet()}`
151
+ : polite
152
+ ? "polite"
153
+ : `no courtesy markers found — ${this.snippet()}`
154
+ );
155
+ }
156
+
157
+ toApproveRefund(): this {
158
+ const pass = APPROVES_REFUND.test(this.result.output);
159
+ return this.record(
160
+ pass,
161
+ "toApproveRefund",
162
+ "agent approves the refund",
163
+ pass ? "approval detected" : `no approval (pending/declined/asked) — ${this.snippet()}`
164
+ );
165
+ }
166
+
167
+ toReferenceOrder(): this {
168
+ const pass = ORDER_REF.test(this.result.output);
169
+ return this.record(
170
+ pass,
171
+ "toReferenceOrder",
172
+ "reply references the order (number/id)",
173
+ pass ? "order reference found" : `no order reference — ${this.snippet()}`
174
+ );
175
+ }
176
+
177
+ toReferencePolicy(): this {
178
+ const pass = POLICY_REF.test(this.result.output);
179
+ return this.record(
180
+ pass,
181
+ "toReferencePolicy",
182
+ "reply references a policy (terms/window/eligibility)",
183
+ pass ? "policy reference found" : `no policy reference — ${this.snippet()}`
184
+ );
185
+ }
186
+
187
+ // --- LLM judge (async, scored) ------------------------------------------
188
+
189
+ toSatisfyBehavior(behavior: string, opts: SatisfyOptions = {}): Promise<this> {
190
+ const p = this.evaluateBehavior(behavior, opts);
191
+ // Register so the runner awaits it even if the caller forgets to. The
192
+ // tracking promise never rejects (the verdict is recorded in the sink).
193
+ pendingSink?.push(p.then(() => undefined, () => undefined));
194
+ return p;
195
+ }
196
+
197
+ private async evaluateBehavior(behavior: string, opts: SatisfyOptions): Promise<this> {
198
+ const threshold = opts.threshold ?? getJudgeThreshold();
199
+ const verdict = await judge(
200
+ { output: this.result.output, toolsUsed: this.result.toolsUsed, behavior },
201
+ threshold
202
+ );
203
+ return this.record(
204
+ verdict.pass,
205
+ "toSatisfyBehavior",
206
+ `behavior "${behavior}" (score ≥ ${threshold.toFixed(2)})`,
207
+ `score ${verdict.score.toFixed(2)} — ${verdict.reasoning}`,
208
+ { score: verdict.score, threshold }
209
+ );
210
+ }
211
+ }
212
+
213
+ /** Entry point: `expect(result)`. */
214
+ export function expect(result: AgentResponse): Expectation {
215
+ return new Expectation(result);
216
+ }
217
+
218
+ export type { Expectation };
package/src/config.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { AgentSource, CheckAIConfig, ResolvedConfig } from "./types";
5
+
6
+ /** Identity helper so config files get full types + autocomplete. */
7
+ export function defineConfig(config: CheckAIConfig): CheckAIConfig {
8
+ return config;
9
+ }
10
+
11
+ const CONFIG_NAMES = [
12
+ "checkai.config.ts",
13
+ "checkai.config.mts",
14
+ "checkai.config.js",
15
+ "checkai.config.mjs",
16
+ ];
17
+
18
+ export function findConfigFile(startDir: string): string | null {
19
+ let dir = resolve(startDir);
20
+ while (true) {
21
+ for (const name of CONFIG_NAMES) {
22
+ const candidate = resolve(dir, name);
23
+ if (existsSync(candidate)) return candidate;
24
+ }
25
+ const parent = dirname(dir);
26
+ if (parent === dir) return null;
27
+ dir = parent;
28
+ }
29
+ }
30
+
31
+ const DEFAULTS = {
32
+ testDir: "checkai",
33
+ judgeModel: "gpt-4.1-mini",
34
+ judgeThreshold: 0.8,
35
+ };
36
+
37
+ function resolveSource(src: AgentSource, rootDir: string): AgentSource {
38
+ if (typeof src !== "string") return src;
39
+ return isAbsolute(src) ? src : resolve(rootDir, src);
40
+ }
41
+
42
+ /** Load and normalize the CheckAI config, resolving paths to absolute. */
43
+ export async function loadConfig(startDir: string = process.cwd()): Promise<ResolvedConfig> {
44
+ const configPath = findConfigFile(startDir);
45
+ if (!configPath) {
46
+ throw new Error(
47
+ `Could not find a checkai.config.ts (searched upward from ${startDir}). ` +
48
+ `Run "checkai init" to create one.`
49
+ );
50
+ }
51
+ const rootDir = dirname(configPath);
52
+ const mod = await import(pathToFileURL(configPath).href);
53
+ const raw: CheckAIConfig = mod.default ?? mod.config ?? mod;
54
+
55
+ const agentsInput: Record<string, AgentSource> = { ...(raw.agents ?? {}) };
56
+ if (raw.agent && Object.keys(agentsInput).length === 0) {
57
+ agentsInput.default = raw.agent;
58
+ }
59
+ if (Object.keys(agentsInput).length === 0) {
60
+ throw new Error("CheckAI config must define at least one agent (via `agents` or `agent`).");
61
+ }
62
+
63
+ const agents: Record<string, AgentSource> = {};
64
+ for (const [name, src] of Object.entries(agentsInput)) {
65
+ agents[name] = resolveSource(src, rootDir);
66
+ }
67
+
68
+ if (raw.defaultAgent && !agents[raw.defaultAgent]) {
69
+ throw new Error(
70
+ `defaultAgent "${raw.defaultAgent}" is not one of the configured agents: ${Object.keys(
71
+ agents
72
+ ).join(", ")}`
73
+ );
74
+ }
75
+
76
+ return {
77
+ configPath,
78
+ rootDir,
79
+ testDir: raw.testDir
80
+ ? isAbsolute(raw.testDir)
81
+ ? raw.testDir
82
+ : resolve(rootDir, raw.testDir)
83
+ : resolve(rootDir, DEFAULTS.testDir),
84
+ judgeModel: raw.judgeModel ?? DEFAULTS.judgeModel,
85
+ judgeThreshold: raw.judgeThreshold ?? DEFAULTS.judgeThreshold,
86
+ agents,
87
+ defaultAgent: raw.defaultAgent ?? Object.keys(agents)[0],
88
+ };
89
+ }
@@ -0,0 +1,57 @@
1
+ import { readdirSync, statSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { setCurrentFile, getTests } from "./registry";
5
+ import type { TestCase } from "./types";
6
+
7
+ /** Recursively collect every *.test.ts file under `dir`. */
8
+ export function findTestFiles(dir: string): string[] {
9
+ if (!existsSync(dir)) return [];
10
+ const out: string[] = [];
11
+ const walk = (current: string) => {
12
+ for (const entry of readdirSync(current)) {
13
+ if (entry === "node_modules" || entry.startsWith(".")) continue;
14
+ const full = join(current, entry);
15
+ if (statSync(full).isDirectory()) walk(full);
16
+ else if (/\.test\.(ts|mts|js|mjs)$/.test(entry)) out.push(full);
17
+ }
18
+ };
19
+ walk(dir);
20
+ return out.sort();
21
+ }
22
+
23
+ /**
24
+ * Import every test file so its `test(...)` calls register. Single-shot per
25
+ * process (ESM caches modules). Throws a clear diagnostic if files exist but
26
+ * nothing registered (the classic two-instances / preserve-symlinks trap).
27
+ */
28
+ export async function discoverTests(testDir: string): Promise<TestCase[]> {
29
+ const files = findTestFiles(testDir);
30
+ for (const file of files) {
31
+ setCurrentFile(file);
32
+ await import(pathToFileURL(file).href);
33
+ }
34
+ const tests = getTests();
35
+
36
+ // Reject duplicate (file, name) pairs — compare aligns results by test identity,
37
+ // so a collision would silently mis-classify regressions. Fail loudly instead.
38
+ const seen = new Set<string>();
39
+ for (const t of tests) {
40
+ const key = `${t.file}::${t.name}`;
41
+ if (seen.has(key)) {
42
+ throw new Error(
43
+ `Duplicate test "${t.name}" in ${t.file}. Test names must be unique within a file.`
44
+ );
45
+ }
46
+ seen.add(key);
47
+ }
48
+
49
+ if (files.length > 0 && tests.length === 0) {
50
+ throw new Error(
51
+ `Found ${files.length} test file(s) under ${testDir} but none registered any tests.\n` +
52
+ `This usually means two copies of "@checklabs/core" were loaded (e.g. Node's ` +
53
+ `--preserve-symlinks is enabled). Disable it so the package resolves once.`
54
+ );
55
+ }
56
+ return tests;
57
+ }
package/src/env.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ /**
5
+ * Tiny .env loader (no dependency). Loads `<rootDir>/.env` into process.env
6
+ * without overriding variables already present in the environment.
7
+ */
8
+ export function loadEnv(rootDir: string): void {
9
+ const file = resolve(rootDir, ".env");
10
+ if (!existsSync(file)) return;
11
+ for (const rawLine of readFileSync(file, "utf8").split(/\r?\n/)) {
12
+ const line = rawLine.trim();
13
+ if (!line || line.startsWith("#")) continue;
14
+ const eq = line.indexOf("=");
15
+ if (eq === -1) continue;
16
+ const key = line.slice(0, eq).trim();
17
+ let value = line.slice(eq + 1).trim();
18
+ if (
19
+ value.length >= 2 &&
20
+ ((value.startsWith('"') && value.endsWith('"')) ||
21
+ (value.startsWith("'") && value.endsWith("'")))
22
+ ) {
23
+ value = value.slice(1, -1);
24
+ }
25
+ if (key && process.env[key] === undefined) process.env[key] = value;
26
+ }
27
+ }
28
+
29
+ /** Resolve the active backend from env + optional override. */
30
+ export function resolveBackend(): "openai" | "mock" {
31
+ const override = (process.env.CHECKAI_BACKEND ?? "auto").toLowerCase();
32
+ if (override === "mock") return "mock";
33
+ if (override === "openai") return "openai";
34
+ return process.env.OPENAI_API_KEY ? "openai" : "mock";
35
+ }
@@ -0,0 +1,103 @@
1
+ import { TEMPLATES, type TestTemplate, type GeneratedScenario } from "./templates";
2
+
3
+ export type { TestTemplate, GeneratedScenario };
4
+ export { TEMPLATES };
5
+
6
+ export interface GenerateOptions {
7
+ /** Explicit template key (support | ecommerce | helpdesk | hr | billing). */
8
+ template?: string;
9
+ /** Free-text agent description (used to infer a template when none is given). */
10
+ description?: string;
11
+ /** Cap the number of scenarios emitted. */
12
+ count?: number;
13
+ }
14
+
15
+ export interface GenerateOutput {
16
+ template: TestTemplate;
17
+ scenarios: GeneratedScenario[];
18
+ code: string;
19
+ }
20
+
21
+ export function listTemplates(): { key: string; title: string; description: string; scenarios: number }[] {
22
+ return Object.values(TEMPLATES).map((t) => ({
23
+ key: t.key,
24
+ title: t.title,
25
+ description: t.description,
26
+ scenarios: t.scenarios.length,
27
+ }));
28
+ }
29
+
30
+ function inferTemplate(description: string): TestTemplate {
31
+ const d = description.toLowerCase();
32
+ if (/cart|checkout|discount|coupon|shipping|store|ecommerce|e-commerce|return/.test(d)) return TEMPLATES.ecommerce;
33
+ if (/password|vpn|laptop|it\b|helpdesk|access|account|device|software/.test(d)) return TEMPLATES.helpdesk;
34
+ if (/pto|vacation|onboarding|benefit|payroll|employee|hr\b|human resources|leave/.test(d)) return TEMPLATES.hr;
35
+ if (/invoice|billing|subscription|charge|payment|plan|fee/.test(d)) return TEMPLATES.billing;
36
+ return TEMPLATES.support;
37
+ }
38
+
39
+ export function selectTemplate(opts: GenerateOptions): TestTemplate {
40
+ if (opts.template) {
41
+ const found = TEMPLATES[opts.template];
42
+ if (!found) {
43
+ throw new Error(
44
+ `Unknown template "${opts.template}". Available: ${Object.keys(TEMPLATES).join(", ")}`
45
+ );
46
+ }
47
+ return found;
48
+ }
49
+ if (opts.description) return inferTemplate(opts.description);
50
+ return TEMPLATES.support;
51
+ }
52
+
53
+ function renderTestFile(
54
+ template: TestTemplate,
55
+ scenarios: GeneratedScenario[],
56
+ description?: string
57
+ ): string {
58
+ const header = [
59
+ `// Generated by \`checkai generate\` — template: ${template.key} (${template.title}).`,
60
+ description ? `// From description: ${description.replace(/\n/g, " ").slice(0, 200)}` : null,
61
+ `//`,
62
+ `// These tests are a STARTING POINT. Review, edit, and keep the ones that fit your`,
63
+ `// agent — then commit them. CheckAI never regenerates this file automatically; you`,
64
+ `// own it from here. (${scenarios.length} scenarios across ${
65
+ new Set(scenarios.map((sc) => sc.category)).size
66
+ } categories.)`,
67
+ ``,
68
+ `import { test, expect } from "@checklabs/checkai";`,
69
+ ``,
70
+ ]
71
+ .filter((line) => line !== null)
72
+ .join("\n");
73
+
74
+ const byCat = new Map<string, GeneratedScenario[]>();
75
+ for (const sc of scenarios) {
76
+ (byCat.get(sc.category) ?? byCat.set(sc.category, []).get(sc.category)!).push(sc);
77
+ }
78
+
79
+ let body = "\n";
80
+ for (const [category, group] of byCat) {
81
+ body += `// --- ${category} ---\n\n`;
82
+ for (const sc of group) {
83
+ const asserts = sc.assertions.map((a) => ` ${a}`).join("\n");
84
+ body += `test(${JSON.stringify(sc.name)}, async ({ agent }) => {\n`;
85
+ body += ` const result = await agent.run(${JSON.stringify(sc.prompt)});\n`;
86
+ body += `${asserts}\n`;
87
+ body += `});\n\n`;
88
+ }
89
+ }
90
+
91
+ return header + body.trimEnd() + "\n";
92
+ }
93
+
94
+ /** Generate a CheckAI test file from a template (and/or description). */
95
+ export function generateTests(opts: GenerateOptions): GenerateOutput {
96
+ const template = selectTemplate(opts);
97
+ let scenarios = template.scenarios;
98
+ if (opts.count && opts.count > 0 && opts.count < scenarios.length) {
99
+ scenarios = scenarios.slice(0, opts.count);
100
+ }
101
+ const code = renderTestFile(template, scenarios, opts.description);
102
+ return { template, scenarios, code };
103
+ }