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.
This commit is contained in:
can1357
2026-06-18 01:57:23 +02:00
parent 265f271197
commit d182db3973
5 changed files with 966 additions and 18 deletions
+2
View File
@@ -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 <pdf>:<member>` 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
+7 -1
View File
@@ -275,7 +275,13 @@ export async function runCli(argv: string[]): Promise<void> {
// 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();
+625 -11
View File
@@ -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<string> {
if (opts.file) {
if (opts.file === STDIN_MARKER) {
@@ -178,11 +241,33 @@ async function evaluate(
return { triggered, notTriggered };
}
async function createTtsrManager(settings?: TtsrSettings): Promise<TtsrManager> {
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<string>();
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<Rule>(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<Rule[]> {
const settingsInstance = await Settings.init({ cwd });
initializeWithSettings(settingsInstance);
const ttsrSettings = settingsInstance.getGroup("ttsr");
if (!ttsrSettings.enabled) {
return [];
}
const result = await loadCapability<Rule>(ruleCapability.id, { cwd });
return filterTtsrRulesForScan(result.items, {
builtinRules: ttsrSettings.builtinRules,
disabledRules: ttsrSettings.disabledRules,
});
}
async function readIsolatedRule(rulePath: string): Promise<Rule> {
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<Rule[]> {
const rule = await readIsolatedRule(rulePath);
return filterTtsrRulesForScan([rule]);
}
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(", ")}`);
@@ -362,6 +470,504 @@ async function runList(json: boolean, cwd: string): Promise<void> {
}
}
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 = /^(?:(?<prefix>tool)(?::(?<tool>[a-z0-9_-]+))?|(?<bare>[a-z0-9_-]+))(?:\((?<path>[^)]+)\))?$/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<RuleMatchDetail | undefined> {
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<boolean | undefined> {
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<ScanFileCandidate[]> {
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<void> {
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<void> {
const cwd = getProjectDir();
if (cmd.action === "test") {
@@ -376,6 +982,14 @@ export async function runTtsrCommand(cmd: TtsrCommandArgs): Promise<void> {
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);
}
+29 -3
View File
@@ -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<void> {
@@ -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,
};
+303 -3
View File
@@ -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"]);