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