From d182db39733675febb790990fa8b60b9effa0996 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 18 Jun 2026 01:57:23 +0200 Subject: [PATCH] feat(coding-agent): implemented gitignore-aware scan command to ttsr cli - Added the `scan` action to `ttsr` CLI with support for gitignore-aware file globbing. - Integrated AST pre-filtering and optimized AST/regex matching to evaluate scan rules. - Implemented file size limits, binary file detection, and custom `--no-gitignore` and `--max-bytes` flags. - Introduced comprehensive test suites validating directory mapping, exclusions, and size-limit enforcement. --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/src/cli.ts | 8 +- packages/coding-agent/src/cli/ttsr-cli.ts | 636 +++++++++++++++++- packages/coding-agent/src/commands/ttsr.ts | 32 +- .../coding-agent/test/cli/ttsr-cli.test.ts | 306 ++++++++- 5 files changed, 966 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d0e169659..8e1b3dd80 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -29,10 +29,12 @@ ### Fixed - Fixed edit seen-line guard mismatch assertion message formatting to report the actual state instead of generic failure notices +- Fixed `omp ttsr scan` to discover files with gitignore-aware native globbing, skip binary/oversized files before text decoding, use a scan-specific matcher, keep default output summary-only, and avoid retaining per-file AST snapshots during scans - Fixed Perplexity web search to use shared OpenAI streaming transports while preserving streamed sources, citations, and related questions - Fixed `read :` errors for unknown PDF images to surface available extracted image names - Fixed puppeteer stealth scripts to use cached Reflect methods (`Reflect_get`, `Reflect_apply`) and `Reflect.apply` instead of live `Reflect`/`Function.prototype.apply` calls, preventing page tampering from leaking through proxy traps. - Fixed Perplexity API-key web search to use shared OpenAI streaming transports while preserving streamed sources and OpenRouter fallback. +- Fixed subagents reporting success after a provider-error turn by preserving real run failures over earlier successful `yield` payloads, and retried bare OpenAI-compatible `finish_reason: "error"` provider failures after partial text instead of stopping immediately ### Security diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index abd674bbb..3a94c6ecf 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -275,7 +275,13 @@ export async function runCli(argv: string[]): Promise { // Declare this module as the worker-host entry now that the active profile // is resolved. The worker-host module is side-effect-free; importing // `@oh-my-pi/pi-utils/env` here would snapshot the wrong agent `.env`. - declareWorkerHostEntry(); + // Gated on `import.meta.main`: only the real CLI process entry is a valid + // worker host. Worker-thread re-entry already returned above at the + // `__omp_worker_` dispatch, and importers (`runCli` in profile-CLI tests, + // SDK embedding) have `import.meta.main === false` — declaring there would + // poison `workerHostEntry()` for the whole test process, forcing eval/stats/ + // browser workers onto the same-realm inline fallback. + if (import.meta.main) declareWorkerHostEntry(); if (resolvedArgv[0] === "--smoke-test") { await runSmokeTest(); diff --git a/packages/coding-agent/src/cli/ttsr-cli.ts b/packages/coding-agent/src/cli/ttsr-cli.ts index 347eae111..108a05004 100644 --- a/packages/coding-agent/src/cli/ttsr-cli.ts +++ b/packages/coding-agent/src/cli/ttsr-cli.ts @@ -10,22 +10,33 @@ * `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 } from "@oh-my-pi/pi-natives"; -import { getProjectDir } from "@oh-my-pi/pi-utils"; +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 { type Rule, ruleCapability } from "../capability/rule"; +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 { TtsrManager, type TtsrMatchContext, type TtsrMatchSource } from "../export/ttsr"; +import type { TtsrManager } from "../export/ttsr"; -export type TtsrAction = "test" | "list"; +export type TtsrAction = "test" | "list" | "scan"; -export const TTSR_ACTIONS: TtsrAction[] = ["test", "list"]; +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; @@ -43,9 +54,23 @@ export interface TtsrTestArgs { 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; } @@ -76,6 +101,44 @@ const STDIN_MARKER = "-"; 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) { @@ -178,11 +241,33 @@ async function evaluate( 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 = new TtsrManager(ttsrSettings); + const manager = await createTtsrManager(ttsrSettings); const result = await loadCapability(ruleCapability.id, { cwd }); bucketRules(result.items, manager, { builtinRules: ttsrSettings.builtinRules, @@ -191,7 +276,21 @@ async function loadProjectTtsrRules(cwd: string): Promise<{ rules: Rule[]; manag return { rules: manager.getRules(), manager }; } -async function loadIsolatedRule(rulePath: string): Promise<{ rules: Rule[]; manager: TtsrManager }> { +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())) { @@ -199,10 +298,14 @@ async function loadIsolatedRule(rulePath: string): Promise<{ rules: Rule[]; mana } const content = await file.text(); const name = path.basename(resolved).replace(/\.(md|mdc)$/, ""); - const rule = buildRuleFromMarkdown(name, content, resolved, createSourceMeta("ttsr-cli", resolved, "project"), { + return buildRuleFromMarkdown(name, content, resolved, createSourceMeta("ttsr-cli", resolved, "project"), { ruleName: name, }); - const manager = new TtsrManager({ +} + +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", @@ -213,12 +316,17 @@ async function loadIsolatedRule(rulePath: string): Promise<{ rules: Rule[]; mana }); 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.`, + `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(", ")}`); @@ -362,6 +470,504 @@ async function runList(json: boolean, cwd: string): Promise { } } +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") { @@ -376,6 +982,14 @@ export async function runTtsrCommand(cmd: TtsrCommandArgs): Promise { 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); } diff --git a/packages/coding-agent/src/commands/ttsr.ts b/packages/coding-agent/src/commands/ttsr.ts index 431a8cfc3..361cbc334 100644 --- a/packages/coding-agent/src/commands/ttsr.ts +++ b/packages/coding-agent/src/commands/ttsr.ts @@ -8,7 +8,14 @@ import * as path from "node:path"; * 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 { + runTtsrCommand, + TTSR_ACTIONS, + TTSR_SOURCES, + type TtsrCommandArgs, + type TtsrScanArgs, + type TtsrTestArgs, +} from "../cli/ttsr-cli"; import type { TtsrMatchSource } from "../export/ttsr"; export default class Ttsr extends Command { @@ -21,7 +28,7 @@ export default class Ttsr extends Command { options: TTSR_ACTIONS, }), snippet: Args.string({ - description: "Inline snippet text to test (ttsr test)", + description: "Inline snippet text to test (ttsr test) or directory to scan (ttsr scan)", required: false, }), }; @@ -45,6 +52,10 @@ export default class Ttsr extends Command { }), verbose: Flags.boolean({ char: "v", description: "Show every evaluated rule, not just triggered ones" }), json: Flags.boolean({ description: "Output JSON" }), + "no-gitignore": Flags.boolean({ description: "Include files excluded by .gitignore (ttsr scan)" }), + "max-bytes": Flags.integer({ + description: "Maximum file size to scan in bytes; 0 disables the limit (ttsr scan)", + }), }; static examples = [ @@ -56,6 +67,9 @@ export default class Ttsr extends Command { "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'", + "omp ttsr scan", + "omp ttsr scan src/", + "omp ttsr scan -r .omp/rules/no-any.md src/", ]; async run(): Promise { @@ -67,7 +81,7 @@ export default class Ttsr extends Command { // --file always wins over the positional. let file = flags.file; let snippet = args.snippet; - if (snippet && !file) { + if (action === "test" && snippet && !file) { const resolved = path.resolve(snippet); if (existsSync(resolved)) { file = resolved; @@ -88,9 +102,21 @@ export default class Ttsr extends Command { } : undefined; + const scan: TtsrScanArgs | undefined = + action === "scan" + ? { + directory: args.snippet, + rule: flags.rule, + gitignore: !flags["no-gitignore"], + maxBytes: flags["max-bytes"], + verbose: flags.verbose, + } + : undefined; + const cmd: TtsrCommandArgs = { action, test, + scan, json: flags.json, }; diff --git a/packages/coding-agent/test/cli/ttsr-cli.test.ts b/packages/coding-agent/test/cli/ttsr-cli.test.ts index b7547979d..5caf2540b 100644 --- a/packages/coding-agent/test/cli/ttsr-cli.test.ts +++ b/packages/coding-agent/test/cli/ttsr-cli.test.ts @@ -1,12 +1,15 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, 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 TtsrScanArgs, type TtsrTestArgs, } from "@oh-my-pi/pi-coding-agent/cli/ttsr-cli"; +import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { getProjectAgentDir, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; // Capture stdout writes so assertions don't leak to the test runner. let stdout = ""; @@ -24,9 +27,12 @@ class ExitSignal extends Error { function captureStreams(): void { stdout = ""; - process.exitCode = undefined; + process.exitCode = 0; process.stdout.write = ((chunk: string | Uint8Array) => { - stdout += chunk.toString(); + // Strip ANSI so assertions match the rendered text rather than chalk's + // color escapes; JSON output paths emit no color codes, so this is a + // no-op for those tests. + stdout += Bun.stripANSI(chunk.toString()); return true; }) as typeof process.stdout.write; process.exit = ((code?: number) => { @@ -186,6 +192,300 @@ describe("omp ttsr", () => { }); }); + describe("scan — directory scanning", () => { + let originalCwd: string; + + beforeEach(() => { + originalCwd = getProjectDir(); + }); + + afterEach(() => { + setProjectDir(originalCwd); + resetSettingsForTest(); + }); + + it("finds matching rules for files in directory matching rules globs/scopes", async () => { + captureStreams(); + + // 1. Create a temporary project root directory + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + + // 2. Set project root + setProjectDir(projectDir); + + // 3. Create a test file in projectDir/src/foo.ts + const testFile = path.join(projectDir, "src/foo.ts"); + await Bun.write(testFile, "const x: any = 1;"); + + // 4. Create isolated rule with glob/scope + const rulePath = await writeTempRule(": any", ["tool:edit(src/**/*.ts)"]); + + const scan: TtsrScanArgs = { + directory: "src", + rule: rulePath, + verbose: true, + }; + + await run({ action: "scan", scan }); + + expect(stdout).toContain("TTSR scan"); + expect(stdout).toContain("Found violations/matches"); + expect(stdout).toContain("src/foo.ts"); + expect(stdout).toContain("test-rule"); + expect(process.exitCode).toBeFalsy(); + }); + + it("keeps default scan output summary-only", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + setProjectDir(projectDir); + + await Bun.write(path.join(projectDir, "src/foo.ts"), "const x: any = 1;"); + + const rulePath = await writeTempRule(": any", ["tool:edit(src/**/*.ts)"]); + const scan: TtsrScanArgs = { + directory: "src", + rule: rulePath, + }; + + await run({ action: "scan", scan }); + + expect(stdout).toContain("Found violations/matches"); + expect(stdout).toContain("rerun with --verbose"); + expect(stdout).not.toContain("src/foo.ts"); + expect(stdout).not.toContain("test-rule"); + }); + + it("outputs json format correctly when json flag is set", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + setProjectDir(projectDir); + + const testFile = path.join(projectDir, "src/foo.ts"); + await Bun.write(testFile, "const x: any = 1;"); + + const rulePath = await writeTempRule(": any", ["tool:edit(src/**/*.ts)"]); + + const scan: TtsrScanArgs = { + directory: "src", + rule: rulePath, + }; + + await run({ action: "scan", scan, json: true }); + + const result = JSON.parse(stdout); + expect(result.files).toHaveLength(1); + expect(result.files[0].filePath).toBe("src/foo.ts"); + expect(result.files[0].matches).toHaveLength(1); + expect(result.files[0].matches[0].name).toBe("test-rule"); + expect(result.summary.totalMatches).toBe(1); + expect(process.exitCode).toBeFalsy(); + }); + + it("reports no matches when files do not match rule conditions", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + setProjectDir(projectDir); + + const testFile = path.join(projectDir, "src/foo.ts"); + await Bun.write(testFile, "const x: unknown = 1;"); + + const rulePath = await writeTempRule(": any", ["tool:edit(src/**/*.ts)"]); + + const scan: TtsrScanArgs = { + directory: "src", + rule: rulePath, + }; + + await run({ action: "scan", scan }); + + expect(stdout).toContain("No rule matches found"); + expect(process.exitCode).toBeFalsy(); + }); + + it("respects project gitignore when scanning a subdirectory", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true }); + fs.mkdirSync(path.join(projectDir, "packages/generated"), { recursive: true }); + fs.mkdirSync(path.join(projectDir, "packages/src"), { recursive: true }); + setProjectDir(projectDir); + + await Bun.write(path.join(projectDir, ".gitignore"), "packages/generated/\n"); + await Bun.write(path.join(projectDir, "packages/generated/ignored.ts"), "const x: any = 1;"); + await Bun.write(path.join(projectDir, "packages/src/kept.ts"), "const x: unknown = 1;"); + + const rulePath = await writeTempRule(": any", ["tool:edit(packages/**/*.ts)"]); + const scan: TtsrScanArgs = { + directory: "packages", + rule: rulePath, + }; + + await run({ action: "scan", scan, json: true }); + + const result = JSON.parse(stdout); + expect(result.files).toHaveLength(0); + expect(result.summary.totalFiles).toBe(1); + expect(result.summary.scannedFiles).toBe(1); + expect(result.summary.totalMatches).toBe(0); + expect(result.summary.gitignore).toBe(true); + }); + + it("honors ttsr.enabled=false for project scans", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + fs.mkdirSync(getProjectAgentDir(projectDir), { recursive: true }); + setProjectDir(projectDir); + + await Bun.write( + path.join(getProjectAgentDir(projectDir), "settings.json"), + JSON.stringify({ ttsr: { enabled: false } }), + ); + await Bun.write(path.join(projectDir, "src/foo.ts"), "const x: any = 1;"); + + await run({ action: "scan", scan: { directory: "src" }, json: true }); + + const result = JSON.parse(stdout); + expect(result.error).toBe("No TTSR rules registered for this project."); + }); + + it("includes ignored files when gitignore filtering is disabled", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true }); + fs.mkdirSync(path.join(projectDir, "packages/generated"), { recursive: true }); + setProjectDir(projectDir); + + await Bun.write(path.join(projectDir, ".gitignore"), "packages/generated/\n"); + await Bun.write(path.join(projectDir, "packages/generated/ignored.ts"), "const x: any = 1;"); + + const rulePath = await writeTempRule(": any", ["tool:edit(packages/**/*.ts)"]); + const scan: TtsrScanArgs = { + directory: "packages", + rule: rulePath, + gitignore: false, + }; + + await run({ action: "scan", scan, json: true }); + + const result = JSON.parse(stdout); + expect(result.files).toHaveLength(1); + expect(result.files[0].filePath).toBe("packages/generated/ignored.ts"); + expect(result.summary.gitignore).toBe(false); + }); + + it("includes hidden source files like GitHub workflows", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, ".github/workflows"), { recursive: true }); + setProjectDir(projectDir); + + await Bun.write(path.join(projectDir, ".github/workflows/ci.yml"), "needs-ttsr: true\n"); + + const rulePath = await writeTempRule("needs-ttsr", ["tool:edit(.github/**/*.yml)"]); + const scan: TtsrScanArgs = { + rule: rulePath, + }; + + await run({ action: "scan", scan, json: true }); + + const result = JSON.parse(stdout); + expect(result.files).toHaveLength(1); + expect(result.files[0].filePath).toBe(".github/workflows/ci.yml"); + expect(result.summary.scannedFiles).toBe(1); + expect(result.summary.totalMatches).toBe(1); + }); + + it("keeps AST-only matches in default summary when type assertion spans whitespace", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + setProjectDir(projectDir); + + await Bun.write(path.join(projectDir, "src/foo.ts"), "const y = (x as\n{ z }).z;"); + const rulePath = await writeTempRule( + "never-matches-regex-zzz", + ["tool:edit(src/**/*.ts)"], + "($X as { $$$BODY }).$PROP", + ); + const scan: TtsrScanArgs = { + directory: "src", + rule: rulePath, + }; + + await run({ action: "scan", scan }); + + expect(stdout).toContain("Found violations/matches"); + expect(stdout).toContain("1 matches across 1 files"); + expect(stdout).not.toContain("src/foo.ts"); + }); + + it("skips files larger than the scan byte limit", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + setProjectDir(projectDir); + + await Bun.write(path.join(projectDir, "src/large.ts"), "const x: any = 1;"); + + const rulePath = await writeTempRule(": any", ["tool:edit(src/**/*.ts)"]); + const scan: TtsrScanArgs = { + directory: "src", + rule: rulePath, + maxBytes: 4, + }; + + await run({ action: "scan", scan, json: true }); + + const result = JSON.parse(stdout); + expect(result.files).toHaveLength(0); + expect(result.summary.totalFiles).toBe(1); + expect(result.summary.scannedFiles).toBe(0); + expect(result.summary.skipped.large).toBe(1); + expect(result.summary.totalMatches).toBe(0); + expect(result.summary.maxBytes).toBe(4); + }); + + it("skips binary-looking files before text matching", async () => { + captureStreams(); + + const projectDir = path.join(import.meta.dir, `.tmp-ttsr-project-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); + setProjectDir(projectDir); + + await Bun.write(path.join(projectDir, "src/blob.ts"), new Uint8Array([0x63, 0x6f, 0x6e, 0x73, 0x74, 0x00])); + + const rulePath = await writeTempRule("const", ["tool:edit(src/**/*.ts)"]); + const scan: TtsrScanArgs = { + directory: "src", + rule: rulePath, + }; + + await run({ action: "scan", scan, json: true }); + + const result = JSON.parse(stdout); + expect(result.files).toHaveLength(0); + expect(result.summary.totalFiles).toBe(1); + expect(result.summary.scannedFiles).toBe(0); + expect(result.summary.skipped.binary).toBe(1); + expect(result.summary.totalMatches).toBe(0); + }); + }); + describe("exports", () => { it("TTSR_SOURCES lists all three match sources", () => { expect(TTSR_SOURCES).toEqual(["text", "thinking", "tool"]);