From 30229b97c46baf805d4f120b8504ec04ce014c2e Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 17 Jun 2026 20:42:19 +0200 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 6 + packages/coding-agent/src/cli-commands.ts | 1 + packages/coding-agent/src/cli/ttsr-cli.ts | 381 ++++++++++++++++++ packages/coding-agent/src/commands/ttsr.ts | 99 +++++ .../coding-agent/test/cli/ttsr-cli.test.ts | 194 +++++++++ 5 files changed, 681 insertions(+) create mode 100644 packages/coding-agent/src/cli/ttsr-cli.ts create mode 100644 packages/coding-agent/src/commands/ttsr.ts create mode 100644 packages/coding-agent/test/cli/ttsr-cli.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c7bb539ea..102a0fe95 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 `, 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 :.png` and listing available image members with `read :` ### Changed diff --git a/packages/coding-agent/src/cli-commands.ts b/packages/coding-agent/src/cli-commands.ts index ec4067cd2..39505193c 100644 --- a/packages/coding-agent/src/cli-commands.ts +++ b/packages/coding-agent/src/cli-commands.ts @@ -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"] }, ]; diff --git a/packages/coding-agent/src/cli/ttsr-cli.ts b/packages/coding-agent/src/cli/ttsr-cli.ts new file mode 100644 index 000000000..347eae111 --- /dev/null +++ b/packages/coding-agent/src/cli/ttsr-cli.ts @@ -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 { + 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 , 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 { + 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 { + 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([...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(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 { + 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 { + 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 { + 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); +} diff --git a/packages/coding-agent/src/commands/ttsr.ts b/packages/coding-agent/src/commands/ttsr.ts new file mode 100644 index 000000000..431a8cfc3 --- /dev/null +++ b/packages/coding-agent/src/commands/ttsr.ts @@ -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 { + 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); + } +} diff --git a/packages/coding-agent/test/cli/ttsr-cli.test.ts b/packages/coding-agent/test/cli/ttsr-cli.test.ts new file mode 100644 index 000000000..b7547979d --- /dev/null +++ b/packages/coding-agent/test/cli/ttsr-cli.test.ts @@ -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 { + try { + await runTtsrCommand(args); + } catch (err) { + if (!(err instanceof ExitSignal)) throw err; + } +} + +async function writeTempRule(condition: string, scope: string[], astCondition?: string): Promise { + // 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 { + 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 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; + 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"]); + }); + }); +});