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:
@@ -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"] },
|
||||
];
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user