/** * 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 fs from "node:fs"; import * as path from "node:path"; import { AstMatchStrictness, astMatch, FileType, type GlobMatch, glob } from "@oh-my-pi/pi-natives"; import { getProjectDir } from "@oh-my-pi/pi-utils/dirs"; import chalk from "chalk"; import { BUILTIN_DEFAULTS_PROVIDER_ID, type Rule, ruleCapability } from "../capability/rule"; import { bucketRules } from "../capability/rule-buckets"; import { Settings } from "../config/settings"; import type { TtsrSettings } from "../config/settings-schema"; import { initializeWithSettings, loadCapability } from "../discovery"; import { buildRuleFromMarkdown, createSourceMeta } from "../discovery/helpers"; import type { TtsrManager } from "../export/ttsr"; export type TtsrAction = "test" | "list" | "scan"; export const TTSR_ACTIONS: TtsrAction[] = ["test", "list", "scan"]; export const TTSR_SOURCES: TtsrMatchSource[] = ["text", "thinking", "tool"]; export type TtsrMatchSource = "text" | "thinking" | "tool"; interface TtsrMatchContext { source: TtsrMatchSource; toolName?: string; filePaths?: string[]; streamKey?: string; } 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 TtsrScanArgs { /** Directory to glob and scan files in. */ directory?: string; /** Path to a rule markdown file to test in isolation (skips project loading). */ rule?: string; /** Respect gitignore files while discovering scan candidates. Defaults to true. */ gitignore?: boolean; /** Maximum file size to scan in bytes; 0 disables the limit. */ maxBytes?: number; /** Show details. */ verbose?: boolean; } export interface TtsrCommandArgs { action: TtsrAction; test?: TtsrTestArgs; scan?: TtsrScanArgs; 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; const BINARY_PROBE_BYTES = 8192; const DEFAULT_MAX_SCAN_BYTES = 5 * 1024 * 1024; type ReadSkipReason = "binary" | "large" | "unreadable"; interface ScanSkipSummary { binary: number; large: number; unreadable: number; noRelevantRules: number; } interface ScanRegexCondition { pattern: string; regex: RegExp; } interface ScanScopePlan { toolName?: string; pathGlob?: Bun.Glob; } interface ScanRulePlan { rule: Rule; globalPathGlobs?: Bun.Glob[]; defaultToolScope: boolean; scopes: ScanScopePlan[]; regexConditions: ScanRegexCondition[]; astConditions: string[]; astPrefilters: RegExp[]; astRequiresFullScan: boolean; } interface ScanFileCandidate { path: string; size?: number; } 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 createTtsrManager(settings?: TtsrSettings): Promise { const { TtsrManager } = await import("../export/ttsr"); return new TtsrManager(settings); } function filterTtsrRulesForScan( rules: readonly Rule[], options: { builtinRules?: boolean; disabledRules?: readonly string[] } = {}, ): Rule[] { const includeBuiltin = options.builtinRules !== false; const disabled = new Set(); for (const raw of options.disabledRules ?? []) { const name = raw.trim(); if (name.length > 0) disabled.add(name); } return rules.filter(rule => { if (disabled.has(rule.name)) return false; if (!includeBuiltin && rule._source?.provider === BUILTIN_DEFAULTS_PROVIDER_ID) return false; return (rule.condition && rule.condition.length > 0) || (rule.astCondition && rule.astCondition.length > 0); }); } 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 = await createTtsrManager(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 loadProjectScanRules(cwd: string): Promise { const settingsInstance = await Settings.init({ cwd }); initializeWithSettings(settingsInstance); const ttsrSettings = settingsInstance.getGroup("ttsr"); if (!ttsrSettings.enabled) { return []; } const result = await loadCapability(ruleCapability.id, { cwd }); return filterTtsrRulesForScan(result.items, { builtinRules: ttsrSettings.builtinRules, disabledRules: ttsrSettings.disabledRules, }); } async function readIsolatedRule(rulePath: string): Promise { 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)$/, ""); return buildRuleFromMarkdown(name, content, resolved, createSourceMeta("ttsr-cli", resolved, "project"), { ruleName: name, }); } async function loadIsolatedRule(rulePath: string): Promise<{ rules: Rule[]; manager: TtsrManager }> { const rule = await readIsolatedRule(rulePath); const manager = await createTtsrManager({ enabled: true, contextMode: "discard", interruptMode: "always", repeatMode: "once", repeatGap: 10, builtinRules: true, disabledRules: [], }); if (!manager.addRule(rule)) { throw new Error( `Rule "${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 loadIsolatedScanRule(rulePath: string): Promise { const rule = await readIsolatedRule(rulePath); return filterTtsrRulesForScan([rule]); } 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`); } } function normalizeScanPath(pathValue: string): string { return pathValue.replaceAll("\\", "/"); } function isWithinDirectory(child: string, parent: string): boolean { const rel = path.relative(parent, child); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); } function matchesScanGlob(glob: Bun.Glob, filePaths: string[] | undefined): boolean { if (!filePaths || filePaths.length === 0) { return false; } for (const filePath of filePaths) { const normalized = normalizeScanPath(filePath); if (glob.match(normalized)) { return true; } const slashIndex = normalized.lastIndexOf("/"); const basename = slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); if (basename !== normalized && glob.match(basename)) { return true; } } return false; } function compileScanPathGlobs(globs: Rule["globs"]): Bun.Glob[] | undefined { if (!globs || globs.length === 0) { return undefined; } const compiled = globs .map(globPattern => globPattern.trim()) .filter(globPattern => globPattern.length > 0) .map(globPattern => new Bun.Glob(globPattern)); return compiled.length > 0 ? compiled : undefined; } function parseScanToolScopeToken(token: string): ScanScopePlan | undefined { const match = /^(?:(?tool)(?::(?[a-z0-9_-]+))?|(?[a-z0-9_-]+))(?:\((?[^)]+)\))?$/i.exec( token, ); if (!match) { return undefined; } const groups = match.groups; const hasToolPrefix = groups?.prefix !== undefined; const toolName = (groups?.tool ?? (hasToolPrefix ? undefined : groups?.bare))?.trim().toLowerCase(); const pathPattern = groups?.path?.trim(); return { toolName, pathGlob: pathPattern ? new Bun.Glob(pathPattern) : undefined, }; } function escapeRegexLiteral(value: string): string { return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); } function compileAstPrefilter(pattern: string): RegExp | undefined { if (/\bas\s*\{/.test(pattern)) { return /\bas\b(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*\{/; } const ignored = new Set(["if", "as", "const", "let", "var", "return", "true", "false", "null", "undefined"]); const tokens = pattern .match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) ?.filter(token => !ignored.has(token) && !/^[A-Z_]+$/.test(token)) .sort((a, b) => b.length - a.length); const token = tokens?.[0]; return token ? new RegExp(`\\b${escapeRegexLiteral(token)}\\b`) : undefined; } function compileScanRulePlans(rules: Rule[]): ScanRulePlan[] { return rules.map(rule => { const scopes: ScanScopePlan[] = []; let defaultToolScope = !rule.scope || rule.scope.length === 0; for (const rawScope of rule.scope ?? []) { const token = rawScope.trim(); const normalizedToken = token.toLowerCase(); if (token.length === 0 || normalizedToken === "text" || normalizedToken === "thinking") { continue; } if (normalizedToken === "tool" || normalizedToken === "toolcall") { scopes.push({}); continue; } const scope = parseScanToolScopeToken(token); if (!scope) { continue; } if (!scope.toolName && !scope.pathGlob) { defaultToolScope = true; continue; } scopes.push(scope); } const regexConditions: ScanRegexCondition[] = []; for (const pattern of rule.condition ?? []) { try { regexConditions.push({ pattern, regex: new RegExp(pattern) }); } catch { // Same behavior as TtsrManager: invalid regex conditions are unusable. } } const astConditions = (rule.astCondition ?? []) .map(pattern => pattern.trim()) .filter(pattern => pattern.length > 0); const astPrefilters: RegExp[] = []; let astRequiresFullScan = false; for (const pattern of astConditions) { const prefilter = compileAstPrefilter(pattern); if (prefilter) { astPrefilters.push(prefilter); } else { astRequiresFullScan = true; } } return { rule, globalPathGlobs: compileScanPathGlobs(rule.globs), defaultToolScope, scopes, regexConditions, astConditions, astPrefilters, astRequiresFullScan, }; }); } function scanRulePlanMatchesPath(plan: ScanRulePlan, filePaths: string[]): boolean { return !plan.globalPathGlobs || plan.globalPathGlobs.some(pathGlob => matchesScanGlob(pathGlob, filePaths)); } function scanRulePlanMatchesToolScope(plan: ScanRulePlan, filePaths: string[]): boolean { if (!scanRulePlanMatchesPath(plan, filePaths)) { return false; } if (plan.defaultToolScope) { return true; } for (const scope of plan.scopes) { if (scope.pathGlob && !matchesScanGlob(scope.pathGlob, filePaths)) { continue; } if (!scope.toolName || scope.toolName === "edit" || scope.toolName === "write") { return true; } } return false; } function scanRulePlanMayMatchAst(plan: ScanRulePlan, fileContent: string): boolean { return ( plan.astConditions.length > 0 && (plan.astRequiresFullScan || plan.astPrefilters.some(prefilter => prefilter.test(fileContent))) ); } async function scanRulePlanMatchesContent( plan: ScanRulePlan, fileContent: string, lang: string | undefined, includeDetails: boolean, ): Promise { let regexHit = false; const matchedRegex: string[] = []; for (const condition of plan.regexConditions) { condition.regex.lastIndex = 0; if (condition.regex.test(fileContent)) { regexHit = true; if (includeDetails) { matchedRegex.push(condition.pattern); } } } const matchedAst: string[] = []; let astHit = false; if ((includeDetails || !regexHit) && lang && plan.astConditions.length > 0) { if (includeDetails) { matchedAst.push(...(await astMatches(plan.rule, fileContent, lang))); astHit = matchedAst.length > 0; } else { try { const result = await astMatch({ patterns: plan.astConditions, source: fileContent, lang, strictness: AstMatchStrictness.Smart, limit: 1, }); astHit = result.matches.length > 0; } catch { astHit = false; } } } if (!regexHit && !astHit) { return undefined; } return { name: plan.rule.name, path: plan.rule.path, sourceProvider: plan.rule._source?.provider, matched: { regex: matchedRegex, ast: matchedAst }, defined: { regex: plan.rule.condition ?? [], ast: plan.rule.astCondition ?? [] }, }; } async function scanAnyAstConditionMatches( plans: ScanRulePlan[], fileContent: string, lang: string | undefined, ): Promise { if (!lang) { return false; } const patterns = plans.flatMap(plan => plan.astConditions); if (patterns.length === 0) { return false; } try { const result = await astMatch({ patterns, source: fileContent, lang, strictness: AstMatchStrictness.Smart, limit: 1, }); if (result.matches.length > 0) { return true; } return result.parseErrors && result.parseErrors.length > 0 ? undefined : false; } catch { return undefined; } } async function discoverScanFiles(scanDir: string, cwd: string, gitignore: boolean): Promise { const globRoot = isWithinDirectory(scanDir, cwd) ? cwd : scanDir; const relativeScanDir = normalizeScanPath(path.relative(globRoot, scanDir)); const pattern = relativeScanDir === "" ? "**/*" : `${relativeScanDir}/**/*`; try { const result = await glob({ pattern, path: globRoot, gitignore, hidden: true, fileType: FileType.File, }); const candidates: ScanFileCandidate[] = []; for (const match of result.matches as GlobMatch[]) { const absPath = path.resolve(globRoot, match.path); const filePath = normalizeScanPath(path.relative(scanDir, absPath)); if ( filePath.length === 0 || filePath.startsWith("..") || path.isAbsolute(filePath) || filePath === ".git" || filePath.startsWith(".git/") ) { continue; } candidates.push({ path: filePath, size: match.size }); } candidates.sort((a, b) => a.path.localeCompare(b.path)); return candidates; } catch { return []; } } async function readScanFileText( absPath: string, maxBytes: number, knownSize: number | undefined, ): Promise<{ content: string } | { skip: ReadSkipReason }> { const file = Bun.file(absPath); try { const fileSize = knownSize ?? file.size; if (maxBytes > 0 && fileSize > maxBytes) { return { skip: "large" }; } const probeBytes = Math.min(fileSize, BINARY_PROBE_BYTES); if (probeBytes > 0) { const prefix = new Uint8Array(await file.slice(0, probeBytes).arrayBuffer()); if (prefix.includes(0)) { return { skip: "binary" }; } } return { content: await file.text() }; } catch { return { skip: "unreadable" }; } } function countSkipped(skipped: ScanSkipSummary): number { return skipped.binary + skipped.large + skipped.unreadable + skipped.noRelevantRules; } async function runScan(args: TtsrScanArgs, json: boolean, cwd: string): Promise { const scanDir = args.directory ? path.resolve(cwd, args.directory) : cwd; if (!fs.existsSync(scanDir)) { if (json) { process.stdout.write(`${JSON.stringify({ error: `Directory not found: ${scanDir}` })}\n`); } else { process.stderr.write(`${chalk.red(`error: scan directory not found: ${scanDir}`)}\n`); } process.exit(1); } const rules = args.rule ? await loadIsolatedScanRule(args.rule) : await loadProjectScanRules(cwd); if (rules.length === 0) { const msg = args.rule ? "Rule registered but produced no TTSR entry." : "No TTSR rules registered for this project."; if (json) { process.stdout.write(`${JSON.stringify({ error: msg })}\n`); } else { process.stderr.write(`${chalk.yellow(msg)}\n`); } process.exit(1); } const scanRulePlans = compileScanRulePlans(rules).filter( plan => plan.regexConditions.length > 0 || plan.astConditions.length > 0, ); if (scanRulePlans.length === 0) { const msg = args.rule ? "Rule registered but produced no usable TTSR condition." : "No usable TTSR rules registered for this project."; if (json) { process.stdout.write(`${JSON.stringify({ error: msg })}\n`); } else { process.stderr.write(`${chalk.yellow(msg)}\n`); } process.exit(1); } const gitignore = args.gitignore ?? true; const maxBytes = Math.max(0, args.maxBytes ?? DEFAULT_MAX_SCAN_BYTES); const includeDetails = json || (args.verbose ?? false); const files = await discoverScanFiles(scanDir, cwd, gitignore); const emptySkipped: ScanSkipSummary = { binary: 0, large: 0, unreadable: 0, noRelevantRules: 0 }; if (files.length === 0) { const msg = `No files found to scan in ${scanDir}`; if (json) { process.stdout.write( `${JSON.stringify({ files: [], summary: { totalFiles: 0, scannedFiles: 0, matchedFiles: 0, totalMatches: 0, evaluatedRules: scanRulePlans.length, skippedFiles: 0, skipped: emptySkipped, gitignore, maxBytes, }, })}\n`, ); } else { process.stdout.write(`${chalk.yellow(msg)}\n`); } return; } const fileResults: Array<{ file: string; matches: RuleMatchDetail[] }> = []; const skipped: ScanSkipSummary = { binary: 0, large: 0, unreadable: 0, noRelevantRules: 0 }; let scannedFiles = 0; let matchedFiles = 0; let totalMatches = 0; for (const candidate of files) { const file = candidate.path; const absPath = path.resolve(scanDir, file); const relToProj = path.relative(cwd, absPath).replaceAll("\\", "/"); const basename = path.basename(absPath); const filePaths = [absPath.replaceAll("\\", "/"), relToProj, basename]; const relevantPlans = scanRulePlans.filter(plan => scanRulePlanMatchesToolScope(plan, filePaths)); if (relevantPlans.length === 0) { skipped.noRelevantRules++; continue; } const readResult = await readScanFileText(absPath, maxBytes, candidate.size); if ("skip" in readResult) { skipped[readResult.skip]++; continue; } const fileContent = readResult.content; const lang = deriveLang(filePaths); scannedFiles++; const fileTriggeredDetails: RuleMatchDetail[] = []; let fileMatchCount = 0; const pendingAstPlans: ScanRulePlan[] = []; for (const plan of relevantPlans) { const detail = includeDetails ? await scanRulePlanMatchesContent(plan, fileContent, lang, true) : await scanRulePlanMatchesContent(plan, fileContent, undefined, false); if (detail) { fileMatchCount++; if (includeDetails) { fileTriggeredDetails.push(detail); } continue; } if (!includeDetails && scanRulePlanMayMatchAst(plan, fileContent)) { pendingAstPlans.push(plan); } } if (!includeDetails && (await scanAnyAstConditionMatches(pendingAstPlans, fileContent, lang)) !== false) { for (const plan of pendingAstPlans) { const detail = await scanRulePlanMatchesContent(plan, fileContent, lang, false); if (!detail) { continue; } fileMatchCount++; } } if (fileMatchCount > 0) { matchedFiles++; totalMatches += fileMatchCount; if (includeDetails) { fileResults.push({ file: relToProj, matches: fileTriggeredDetails, }); } } } if (json) { process.stdout.write( `${JSON.stringify({ files: fileResults.map(fr => ({ filePath: fr.file, matches: fr.matches.map(m => ({ name: m.name, path: m.path, matched: m.matched, })), })), summary: { totalFiles: files.length, scannedFiles, matchedFiles, totalMatches, evaluatedRules: scanRulePlans.length, skippedFiles: countSkipped(skipped), skipped, gitignore, maxBytes, }, })}\n`, ); } else { process.stdout.write( `${chalk.bold("TTSR scan")} — directory=${chalk.cyan(scanDir)} files=${chalk.dim(files.length)} scanned=${chalk.dim(scannedFiles)} rules=${chalk.dim(scanRulePlans.length)} gitignore=${chalk.dim(gitignore ? "on" : "off")} max-bytes=${chalk.dim(maxBytes === 0 ? "off" : String(maxBytes))}\n`, ); if (countSkipped(skipped) > 0) { process.stdout.write( `${chalk.dim(` skipped: binary=${skipped.binary} large=${skipped.large} unreadable=${skipped.unreadable} no-relevant-rules=${skipped.noRelevantRules}`)}\n`, ); } if (matchedFiles === 0) { process.stdout.write( `${chalk.green.bold("No rule matches found.")} (evaluated ${rules.length} rules on ${scannedFiles}/${files.length} files)\n`, ); } else { process.stdout.write( `${chalk.red.bold("Found violations/matches:")} (${totalMatches} matches across ${matchedFiles} files)\n`, ); if (!includeDetails) { process.stdout.write(`${chalk.dim(" rerun with --verbose to list matched files and conditions")}\n`); return; } process.stdout.write("\n"); for (const fr of fileResults) { process.stdout.write(`${chalk.bold.underline(fr.file)}\n`); for (const detail of fr.matches) { renderRuleDetail(detail, true); } process.stdout.write("\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; } if (cmd.action === "scan") { if (!cmd.scan) { process.stderr.write(`${chalk.red("error: scan arguments missing")}\n`); process.exit(1); } await runScan(cmd.scan, cmd.json ?? false, cwd); return; } process.stderr.write(`${chalk.red(`error: unknown ttsr action: ${cmd.action}`)}\n`); process.exit(1); }