feat(cli): added TTSR CLI command with list and test execution modes

- Added a top-level `ttsr` CLI command with `list` and `test` actions.
- Added snippet input handling for inline text, `--file` path, and stdin via `--file -`.
- Added test-mode context inference and result output for matched and unmatched rules.
- Added CLI tests for source inference, explicit source overrides, JSON output, and list mode.
This commit is contained in:
can1357
2026-06-17 20:42:19 +02:00
parent c81adde1f1
commit 30229b97c4
5 changed files with 681 additions and 0 deletions
+6
View File
@@ -1,8 +1,14 @@
# Changelog
## [Unreleased]
### Added
- Added `omp ttsr` top-level CLI command to inspect and test Time-Traveling Stream Rules
- Added `omp ttsr list` to enumerate all project/user-loaded TTSR rules with their conditions, scope, and source metadata
- Added `omp ttsr test` to run snippets through the real TTSR matching pipeline with inline text, `--file <path>`, or stdin via `--file -`
- Added `--json` output to `omp ttsr test` and `omp ttsr list` for machine-readable reporting
- Added `--rule`, `--source`, `--tool`, `--path`, and `--verbose` options to `omp ttsr test` to control matching context and inspection details
- Added support for reading embedded PDF images via `read <pdf>:<image>.png` and listing available image members with `read <pdf>:`
### Changed
@@ -39,6 +39,7 @@ export const commands: CommandEntry[] = [
{ name: "usage", load: () => import("./commands/usage").then(m => m.default) },
{ name: "tiny-models", load: () => import("./commands/tiny-models").then(m => m.default) },
{ name: "token", load: () => import("./commands/token").then(m => m.default) },
{ name: "ttsr", load: () => import("./commands/ttsr").then(m => m.default) },
{ name: "worktree", load: () => import("./commands/worktree").then(m => m.default), aliases: ["wt"] },
{ name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
];
+381
View File
@@ -0,0 +1,381 @@
/**
* TTSR CLI command handlers.
*
* `omp ttsr test` — feed a snippet (inline text, `--file`, or stdin) through the
* real TTSR matching pipeline (`TtsrManager.checkSnapshot` for regex conditions,
* `checkAstSnapshot` for ast-grep conditions) and report which rules would
* trigger. The match context (`--source`, `--tool`, `--path`) is honored so
* glob/AST/scope-scoped rules evaluate the same way they do in a live session.
*
* `omp ttsr list` — show every TTSR-registered rule the current project/user
* config would load, with its conditions, scope, and source.
*/
import * as path from "node:path";
import { AstMatchStrictness, astMatch } from "@oh-my-pi/pi-natives";
import { getProjectDir } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { type Rule, ruleCapability } from "../capability/rule";
import { bucketRules } from "../capability/rule-buckets";
import { Settings } from "../config/settings";
import { initializeWithSettings, loadCapability } from "../discovery";
import { buildRuleFromMarkdown, createSourceMeta } from "../discovery/helpers";
import { TtsrManager, type TtsrMatchContext, type TtsrMatchSource } from "../export/ttsr";
export type TtsrAction = "test" | "list";
export const TTSR_ACTIONS: TtsrAction[] = ["test", "list"];
export const TTSR_SOURCES: TtsrMatchSource[] = ["text", "thinking", "tool"];
export interface TtsrTestArgs {
/** Inline snippet text. */
snippet?: string;
/** Snippet file path, or `-` for stdin. */
file?: string;
/** Path to a rule markdown file to test in isolation (skips project loading). */
rule?: string;
/** TTSR match source; when omitted, inferred from --file (tool for source files, text otherwise). */
source?: TtsrMatchSource;
/** Tool name when `source === "tool"` (e.g. "edit", "write"). */
tool?: string;
/** Candidate file path used for scope/glob matching and AST language inference. */
filePath?: string;
/** Show every evaluated rule, not just triggered ones. */
verbose?: boolean;
}
export interface TtsrCommandArgs {
action: TtsrAction;
test?: TtsrTestArgs;
json?: boolean;
}
interface RuleMatchDetail {
name: string;
path: string;
sourceProvider?: string;
/** Conditions that matched the snippet. */
matched: { regex: string[]; ast: string[] };
/** All conditions defined on the rule (for verbose display). */
defined: { regex: string[]; ast: string[] };
skippedAst?: string;
}
interface TestReport {
source: TtsrMatchSource;
tool?: string;
filePath?: string;
snippetPreview: string;
snippetBytes: number;
evaluated: number;
triggered: RuleMatchDetail[];
notTriggered: RuleMatchDetail[];
}
const STDIN_MARKER = "-";
/** Extensions treated as source files for default tool-context inference. */
const SOURCE_FILE_EXT =
/^\.(ts|tsx|js|jsx|mjs|cjs|rs|py|go|java|kt|swift|c|cc|cpp|h|hpp|rb|php|lua|css|scss|html|json|ya?ml|toml|md|mdc)$/i;
async function readSnippet(opts: { snippet?: string; file?: string }): Promise<string> {
if (opts.file) {
if (opts.file === STDIN_MARKER) {
return await Bun.stdin.text();
}
const resolved = path.resolve(opts.file);
const file = Bun.file(resolved);
if (!(await file.exists())) {
throw new Error(`Snippet file not found: ${resolved}`);
}
return await file.text();
}
if (opts.snippet !== undefined) return opts.snippet;
if (process.stdin.isTTY === false) return await Bun.stdin.text();
throw new Error("No snippet provided. Pass inline text, --file <path>, or pipe via --file -.");
}
function previewSnippet(text: string): string {
const single = text.replace(/\s+/g, " ").trim();
return single.length > 80 ? `${single.slice(0, 77)}…` : single;
}
function deriveLang(filePaths: string[] | undefined): string | undefined {
for (const filePath of filePaths ?? []) {
const ext = path.extname(filePath.replaceAll("\\", "/"));
if (ext.length > 1) return ext.slice(1).toLowerCase();
}
return undefined;
}
async function regexMatches(rule: Rule, snippet: string): Promise<string[]> {
const out: string[] = [];
for (const pattern of rule.condition ?? []) {
try {
if (new RegExp(pattern).test(snippet)) out.push(pattern);
} catch {
// Invalid regex — skip; the manager already warned at registration.
}
}
return out;
}
async function astMatches(rule: Rule, snippet: string, lang: string): Promise<string[]> {
const out: string[] = [];
for (const pattern of rule.astCondition ?? []) {
try {
const result = await astMatch({
patterns: [pattern],
source: snippet,
lang,
strictness: AstMatchStrictness.Smart,
limit: 1,
});
if (result.totalMatches > 0) out.push(pattern);
} catch {
// Treat as no match (manager logs at runtime).
}
}
return out;
}
/**
* Run the snippet through the manager's real match paths and collect, for each
* triggered rule, which of its conditions fired. Returns triggered + the full
* evaluated set (so callers can render not-triggered entries too).
*/
async function evaluate(
manager: TtsrManager,
rules: readonly Rule[],
snippet: string,
context: TtsrMatchContext,
): Promise<{ triggered: RuleMatchDetail[]; notTriggered: RuleMatchDetail[] }> {
const regexHit = manager.checkSnapshot(snippet, context);
const astHit =
context.source === "tool" && context.filePaths && context.filePaths.length > 0
? await manager.checkAstSnapshot(snippet, context)
: [];
const hitNames = new Set<string>([...regexHit, ...astHit].map(r => r.name));
const lang = deriveLang(context.filePaths);
const astEligible = context.source === "tool" && !!lang;
const triggered: RuleMatchDetail[] = [];
const notTriggered: RuleMatchDetail[] = [];
for (const rule of rules) {
const regex = await regexMatches(rule, snippet);
const ast = astEligible ? await astMatches(rule, snippet, lang!) : [];
const detail: RuleMatchDetail = {
name: rule.name,
path: rule.path,
sourceProvider: rule._source?.provider,
matched: { regex, ast },
defined: { regex: rule.condition ?? [], ast: rule.astCondition ?? [] },
};
if (!astEligible && (rule.astCondition ?? []).length > 0) {
detail.skippedAst = "astCondition requires --source tool and a --path with a file extension";
}
(hitNames.has(rule.name) ? triggered : notTriggered).push(detail);
}
return { triggered, notTriggered };
}
async function loadProjectTtsrRules(cwd: string): Promise<{ rules: Rule[]; manager: TtsrManager }> {
const settingsInstance = await Settings.init({ cwd });
initializeWithSettings(settingsInstance);
const ttsrSettings = settingsInstance.getGroup("ttsr");
const manager = new TtsrManager(ttsrSettings);
const result = await loadCapability<Rule>(ruleCapability.id, { cwd });
bucketRules(result.items, manager, {
builtinRules: ttsrSettings.builtinRules,
disabledRules: ttsrSettings.disabledRules,
});
return { rules: manager.getRules(), manager };
}
async function loadIsolatedRule(rulePath: string): Promise<{ rules: Rule[]; manager: TtsrManager }> {
const resolved = path.resolve(rulePath);
const file = Bun.file(resolved);
if (!(await file.exists())) {
throw new Error(`Rule file not found: ${resolved}`);
}
const content = await file.text();
const name = path.basename(resolved).replace(/\.(md|mdc)$/, "");
const rule = buildRuleFromMarkdown(name, content, resolved, createSourceMeta("ttsr-cli", resolved, "project"), {
ruleName: name,
});
const manager = new TtsrManager({
enabled: true,
contextMode: "discard",
interruptMode: "always",
repeatMode: "once",
repeatGap: 10,
builtinRules: true,
disabledRules: [],
});
if (!manager.addRule(rule)) {
throw new Error(
`Rule "${name}" has no usable TTSR condition. Add a \`condition\` (regex) or \`astCondition\` (ast-grep pattern) to its frontmatter.`,
);
}
return { rules: manager.getRules(), manager };
}
async function runTest(args: TtsrTestArgs, json: boolean, cwd: string): Promise<void> {
if (args.source && !TTSR_SOURCES.includes(args.source)) {
throw new Error(`Invalid --source: ${args.source}. Expected one of: ${TTSR_SOURCES.join(", ")}`);
}
const snippet = await readSnippet(args);
// Infer match context: when the user points --file at a source file and
// doesn't pick a source, default to tool/edit with that path so tool-scoped
// rules (the common case, e.g. tool:edit(*.ts)) match like they would live.
const filePath = args.filePath ?? (args.file && args.file !== STDIN_MARKER ? path.resolve(args.file) : undefined);
const source: TtsrMatchSource =
args.source ?? (filePath && SOURCE_FILE_EXT.test(path.extname(filePath)) ? "tool" : "text");
const tool = args.tool ?? (source === "tool" ? "edit" : undefined);
const context: TtsrMatchContext = {
source,
toolName: tool,
filePaths: filePath ? [filePath] : undefined,
};
const { rules, manager } = args.rule ? await loadIsolatedRule(args.rule) : await loadProjectTtsrRules(cwd);
if (rules.length === 0) {
const msg = args.rule
? "Rule registered but produced no TTSR entry."
: "No TTSR rules registered for this project. Add a `condition` or `astCondition` to a rule file, then re-run.";
if (json) {
process.stdout.write(`${JSON.stringify({ error: msg })}\n`);
} else {
process.stderr.write(`${chalk.yellow(msg)}\n`);
}
process.exit(1);
}
const { triggered, notTriggered } = await evaluate(manager, rules, snippet, context);
const report: TestReport = {
source,
tool,
filePath,
snippetPreview: previewSnippet(snippet),
snippetBytes: snippet.length,
evaluated: rules.length,
triggered,
notTriggered,
};
if (json) {
process.stdout.write(`${JSON.stringify(report)}\n`);
return;
}
renderTestReport(report, args.verbose ?? false, args.rule !== undefined);
}
function renderTestReport(report: TestReport, verbose: boolean, isolated: boolean): void {
const ctxLabel = report.source === "tool" ? `tool:${report.tool ?? "?"}` : report.source;
const pathLabel = report.filePath ? ` path=${report.filePath}` : "";
process.stdout.write(
`${chalk.bold("TTSR test")} — source=${chalk.cyan(ctxLabel)}${pathLabel} snippet=${chalk.dim(`${report.snippetBytes}b`)}\n`,
);
process.stdout.write(`${chalk.dim(` "${report.snippetPreview}"`)}\n\n`);
if (report.triggered.length === 0) {
process.stdout.write(`${chalk.red("No rules triggered.")} (evaluated ${report.evaluated})\n`);
} else {
process.stdout.write(`${chalk.green.bold(`Triggered (${report.triggered.length})`)}\n`);
for (const detail of report.triggered) renderRuleDetail(detail, true);
}
if (verbose && report.notTriggered.length > 0) {
process.stdout.write(`\n${chalk.dim(`Not triggered (${report.notTriggered.length})`)}\n`);
for (const detail of report.notTriggered) renderRuleDetail(detail, false);
}
if (isolated && report.triggered.length === 0) {
process.exitCode = 1;
}
}
function renderRuleDetail(detail: RuleMatchDetail, hit: boolean): void {
const mark = hit ? chalk.green("✓") : chalk.red("✗");
const condParts: string[] = [];
// For triggered rules, show which conditions fired. For not-triggered
// rules (verbose), show the rule's full condition set so users can see
// what would match.
const regex = hit ? detail.matched.regex : detail.defined.regex;
const ast = hit ? detail.matched.ast : detail.defined.ast;
if (regex.length > 0) {
condParts.push(`condition: ${regex.map(c => chalk.yellow(`/${c}/`)).join(", ")}`);
}
if (ast.length > 0) {
condParts.push(`astCondition: ${ast.map(c => chalk.magenta(c)).join(", ")}`);
}
if (detail.skippedAst) {
condParts.push(chalk.dim(`astCondition: ${detail.skippedAst}`));
}
const condLabel = condParts.length > 0 ? condParts.join(" ") : chalk.dim("no active conditions");
const provider = detail.sourceProvider ? chalk.dim(` [${detail.sourceProvider}]`) : "";
process.stdout.write(` ${mark} ${chalk.bold(detail.name)} ${condLabel}${provider}\n`);
}
async function runList(json: boolean, cwd: string): Promise<void> {
const { rules } = await loadProjectTtsrRules(cwd);
if (json) {
process.stdout.write(
`${JSON.stringify(
rules.map(r => ({
name: r.name,
path: r.path,
provider: r._source?.provider,
condition: r.condition ?? [],
astCondition: r.astCondition ?? [],
scope: r.scope ?? [],
globs: r.globs ?? [],
description: r.description,
})),
)}\n`,
);
return;
}
if (rules.length === 0) {
process.stdout.write(`${chalk.yellow("No TTSR rules registered for this project.")}\n`);
return;
}
process.stdout.write(`${chalk.bold(`TTSR rules (${rules.length})`)}\n`);
for (const rule of rules) {
const condParts: string[] = [];
if ((rule.condition ?? []).length > 0) condParts.push(`condition: ${rule.condition!.join(", ")}`);
if ((rule.astCondition ?? []).length > 0) condParts.push(`astCondition: ${rule.astCondition!.join(", ")}`);
if ((rule.scope ?? []).length > 0) condParts.push(`scope: ${rule.scope!.join(", ")}`);
if ((rule.globs ?? []).length > 0) condParts.push(`globs: ${rule.globs!.join(", ")}`);
const provider = rule._source?.provider ? chalk.dim(` [${rule._source.provider}]`) : "";
process.stdout.write(
` ${chalk.bold(rule.name)}${provider} ${chalk.dim(condParts.join(" ") || "no conditions")}\n`,
);
if (rule.description) process.stdout.write(`${chalk.dim(` ${rule.description}`)}\n`);
}
}
export async function runTtsrCommand(cmd: TtsrCommandArgs): Promise<void> {
const cwd = getProjectDir();
if (cmd.action === "test") {
if (!cmd.test) {
process.stderr.write(`${chalk.red("error: `ttsr test` requires a snippet, --file, or piped stdin")}\n`);
process.exit(1);
}
await runTest(cmd.test, cmd.json ?? false, cwd);
return;
}
if (cmd.action === "list") {
await runList(cmd.json ?? false, cwd);
return;
}
process.stderr.write(`${chalk.red(`error: unknown ttsr action: ${cmd.action}`)}\n`);
process.exit(1);
}
@@ -0,0 +1,99 @@
import { existsSync } from "node:fs";
import * as path from "node:path";
/**
* `omp ttsr` — inspect and test Time-Traveling Stream Rules.
*
* `omp ttsr test` feeds a snippet (inline, --file, or stdin) through the real
* TTSR matching pipeline and reports which rules would trigger. `omp ttsr list`
* shows every TTSR-registered rule the current project/user config would load.
*/
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
import { runTtsrCommand, TTSR_ACTIONS, TTSR_SOURCES, type TtsrCommandArgs, type TtsrTestArgs } from "../cli/ttsr-cli";
import type { TtsrMatchSource } from "../export/ttsr";
export default class Ttsr extends Command {
static description = "Inspect and test Time-Traveling Stream Rules (TTSR)";
static args = {
action: Args.string({
description: "TTSR action",
required: false,
options: TTSR_ACTIONS,
}),
snippet: Args.string({
description: "Inline snippet text to test (ttsr test)",
required: false,
}),
};
static flags = {
file: Flags.string({ description: "Snippet file path, or - for stdin (ttsr test)" }),
rule: Flags.string({
char: "r",
description: "Rule markdown file to test in isolation (skips project rule loading)",
}),
source: Flags.string({
description: "Match source: text, thinking, or tool (inferred from --file when omitted)",
options: TTSR_SOURCES,
}),
tool: Flags.string({
description: "Tool name when source is tool (e.g. edit, write); defaults to edit",
}),
path: Flags.string({
char: "p",
description: "Candidate file path for scope/glob matching and AST language inference",
}),
verbose: Flags.boolean({ char: "v", description: "Show every evaluated rule, not just triggered ones" }),
json: Flags.boolean({ description: "Output JSON" }),
};
static examples = [
"omp ttsr list",
"omp ttsr test 'const x: any = 1'",
"omp ttsr test src/foo.ts",
"omp ttsr test --file src/foo.ts",
"omp ttsr test --file src/foo.ts --source text",
"omp ttsr test --rule .omp/rules/no-any.md --source tool --path src/foo.ts 'const x: any = 1'",
"echo 'Box::leak(&mut v)' | omp ttsr test --file - --path src/lib.rs",
"omp ttsr test --source tool --tool edit --path src/foo.ts 'const x: any = 1'",
];
async run(): Promise<void> {
const { args, flags } = await this.parse(Ttsr);
const action = (args.action ?? "list") as (typeof TTSR_ACTIONS)[number];
// A positional that resolves to an existing file is a snippet file, not
// inline text — so `omp ttsr test src/foo.ts` works without --file.
// --file always wins over the positional.
let file = flags.file;
let snippet = args.snippet;
if (snippet && !file) {
const resolved = path.resolve(snippet);
if (existsSync(resolved)) {
file = resolved;
snippet = undefined;
}
}
const test: TtsrTestArgs | undefined =
action === "test"
? {
snippet,
file,
rule: flags.rule,
source: flags.source as TtsrMatchSource | undefined,
tool: flags.tool,
filePath: flags.path,
verbose: flags.verbose,
}
: undefined;
const cmd: TtsrCommandArgs = {
action,
test,
json: flags.json,
};
await runTtsrCommand(cmd);
}
}
@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import {
runTtsrCommand,
TTSR_SOURCES,
type TtsrCommandArgs,
type TtsrTestArgs,
} from "@oh-my-pi/pi-coding-agent/cli/ttsr-cli";
// Capture stdout writes so assertions don't leak to the test runner.
let stdout = "";
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalExit = process.exit;
const originalExitCode = process.exitCode;
class ExitSignal extends Error {
constructor(readonly code?: number) {
super("exit");
this.name = "ExitSignal";
}
}
function captureStreams(): void {
stdout = "";
process.exitCode = undefined;
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout += chunk.toString();
return true;
}) as typeof process.stdout.write;
process.exit = ((code?: number) => {
throw new ExitSignal(code);
}) as typeof process.exit;
}
function restoreStreams(): void {
process.stdout.write = originalStdoutWrite;
process.exit = originalExit;
process.exitCode = originalExitCode;
}
async function run(args: TtsrCommandArgs): Promise<void> {
try {
await runTtsrCommand(args);
} catch (err) {
if (!(err instanceof ExitSignal)) throw err;
}
}
async function writeTempRule(condition: string, scope: string[], astCondition?: string): Promise<string> {
// Stable basename "test-rule.md" so buildRuleFromMarkdown derives name
// "test-rule" — assertions rely on it. Each call uses a unique parent dir
// to avoid collisions across tests.
const dir = path.join(import.meta.dir, `.tmp-ttsr-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, "test-rule.md");
const fm: string[] = [`description: test rule`, `condition: "${condition.replace(/"/g, '\\"')}"`];
if (astCondition) fm.push(`astCondition: "${astCondition.replace(/"/g, '\\"')}"`);
fm.push(`scope: [${scope.map(s => `"${s}"`).join(", ")}]`);
await Bun.write(tmp, `---\n${fm.join("\n")}\n---\nbody\n`);
return tmp;
}
async function writeTempSnippet(content: string, ext: string): Promise<string> {
const dir = path.join(import.meta.dir, `.tmp-ttsr-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `snippet.${ext}`);
await Bun.write(tmp, content);
return tmp;
}
function cleanupTmp(): void {
for (const entry of fs.readdirSync(import.meta.dir)) {
if (entry.startsWith(".tmp-ttsr-")) {
fs.rmSync(path.join(import.meta.dir, entry), { force: true, recursive: true });
}
}
}
describe("omp ttsr", () => {
afterEach(() => {
restoreStreams();
cleanupTmp();
});
describe("test — context inference and matching", () => {
it("infers tool/edit context when a positional resolves to a .ts file and --source is omitted", async () => {
captureStreams();
const rulePath = await writeTempRule(": any", ["tool:edit(*.ts)"]);
// Simulate `omp ttsr test --rule <rule> src/foo.ts`: the command layer
// resolves a file positional into `file`, but the CLI handler's own
// inference (source from file extension) is exercised when source is
// unset. Pass file + filePath so the handler infers tool context.
const snippetPath = await writeTempSnippet("const x: any = 1", "ts");
const test: TtsrTestArgs = {
rule: rulePath,
file: snippetPath,
source: undefined,
};
await run({ action: "test", test });
expect(stdout).toContain("source=tool:edit");
expect(stdout).toContain("Triggered");
expect(stdout).toContain("test-rule");
});
it("defaults to source=text for inline snippet with no file", async () => {
captureStreams();
const rulePath = await writeTempRule(": any", ["tool:edit(*.ts)"]);
const test: TtsrTestArgs = {
rule: rulePath,
snippet: "const x: any = 1",
source: undefined,
};
await run({ action: "test", test });
expect(stdout).toContain("source=text");
// tool-scoped rule does not fire under text source
expect(stdout).toContain("No rules triggered");
});
it("does not trigger a tool-scoped rule when --source text is explicit", async () => {
captureStreams();
const rulePath = await writeTempRule(": any", ["tool:edit(*.ts)"]);
const test: TtsrTestArgs = {
rule: rulePath,
source: "text",
snippet: "const x: any = 1",
};
await run({ action: "test", test });
expect(stdout).toContain("No rules triggered");
});
it("reports JSON with matched/defined condition arrays", async () => {
captureStreams();
const rulePath = await writeTempRule(": any", ["tool:edit(*.ts)"]);
const test: TtsrTestArgs = {
rule: rulePath,
source: "tool",
filePath: "src/foo.ts",
snippet: "const x: any = 1",
};
await run({ action: "test", test, json: true });
const report = JSON.parse(stdout);
expect(report.triggered).toHaveLength(1);
expect(report.triggered[0].matched.regex).toContain(": any");
expect(report.triggered[0].defined.regex).toContain(": any");
expect(report.source).toBe("tool");
expect(report.tool).toBe("edit");
});
it("astCondition matches via checkAstSnapshot with a tool + .ts path", async () => {
captureStreams();
const rulePath = await writeTempRule(
"never-matches-regex-zzz",
["tool:edit(*.ts)"],
"($X as { $$$BODY }).$PROP",
);
const test: TtsrTestArgs = {
rule: rulePath,
source: "tool",
filePath: "src/foo.ts",
snippet: "const y = (x as { z }).z;",
};
await run({ action: "test", test });
expect(stdout).toContain("Triggered");
expect(stdout).toContain("astCondition");
});
});
describe("list", () => {
it("emits a JSON array of rule objects with expected shape", async () => {
captureStreams();
await run({ action: "list", json: true });
const arr = JSON.parse(stdout);
expect(Array.isArray(arr)).toBe(true);
// Assert structural shape only — the exact rule set depends on
// user/project settings, which we don't isolate here.
if (arr.length > 0) {
const first = arr[0] as Record<string, unknown>;
expect(first).toHaveProperty("name");
expect(first).toHaveProperty("path");
expect(first).toHaveProperty("condition");
expect(first).toHaveProperty("astCondition");
expect(first).toHaveProperty("scope");
}
});
});
describe("exports", () => {
it("TTSR_SOURCES lists all three match sources", () => {
expect(TTSR_SOURCES).toEqual(["text", "thinking", "tool"]);
});
});
});