Files
oh-my-pi/packages/coding-agent/src/capability/rule.ts
T
roboomp 86824c94e9 fix(ttsr): registered rules with inline regex flags and malformed scope
Rules whose condition led with a PCRE-style inline flag group (e.g.
`(?i)`) never registered: `new RegExp("(?i)...")` throws in Bun/JS, so
the condition failed to compile and `TtsrManager.addRule` dropped the
rule as having zero usable conditions.

- Add `compileRuleCondition` in capability/rule.ts translating a leading
  `(?i)`/`(?m)`/`(?s)` group into native RegExp flags; wire it into the
  TtsrManager and both ttsr-cli compile sites.
- Strip surrounding quotes from scope tokens so a malformed
  `scope: "text","thinking"` recovers to canonical `text`/`thinking`.
- Reparse each value in parseFrontmatter's YAML fallback so one bad line
  can't leave sibling values wrapped in literal quotes.

Fixes #4796
2026-07-14 19:01:47 +00:00

299 lines
8.6 KiB
TypeScript

/**
* Rules Capability
*
* Project-specific rules from Cursor (.mdc), Windsurf (.md), and Cline formats.
* Translated to a canonical shape regardless of source format.
*/
import { defineCapability } from ".";
import type { SourceMeta } from "./types";
const CONDITION_GLOB_SCOPE_TOOLS = ["edit", "write"] as const;
/**
* Provider id for the bundled default rules shipped with the agent.
* Lowest priority, so any user/project/tool rule of the same name overrides
* a bundled default. Also used to gate the whole bundled set via
* `ttsr.builtinRules`.
*/
export const BUILTIN_DEFAULTS_PROVIDER_ID = "builtin-defaults";
/**
* Parsed frontmatter from rule files.
*/
export interface RuleFrontmatter {
description?: string;
globs?: string[];
alwaysApply?: boolean;
/** New key for TTSR match conditions. */
condition?: string | string[];
/** TTSR match condition(s) expressed as ast-grep patterns (edit/write streams only). */
astCondition?: string | string[];
/** New key for TTSR stream scope. */
scope?: string | string[];
/** Per-rule TTSR interrupt mode override. */
interruptMode?: "never" | "prose-only" | "tool-only" | "always";
[key: string]: unknown;
}
/**
* A rule providing project-specific guidance and constraints.
*/
export interface Rule {
/** Rule name (derived from filename) */
name: string;
/** Absolute path to rule file */
path: string;
/** Rule content (after frontmatter stripped) */
content: string;
/** Globs this rule applies to (if any) */
globs?: string[];
/** Whether to always include this rule */
alwaysApply?: boolean;
/** Description (for agent-requested rules) */
description?: string;
/** Regex condition(s) that can trigger TTSR interruption. */
condition?: string[];
/** ast-grep pattern condition(s) that can trigger TTSR interruption (edit/write streams only). */
astCondition?: string[];
/** Optional stream scope tokens (for example: text, thinking, tool:edit(*.ts)). */
scope?: string[];
/** Per-rule TTSR interrupt mode override (falls back to global ttsr.interruptMode). */
interruptMode?: "never" | "prose-only" | "tool-only" | "always";
/** Source metadata */
_source: SourceMeta;
}
function normalizeRuleField(value: unknown): string[] | undefined {
if (typeof value === "string") {
const token = value.trim();
return token.length > 0 ? [token] : undefined;
}
if (!Array.isArray(value)) {
return undefined;
}
const tokens = value
.filter((item): item is string => typeof item === "string")
.map(item => item.trim())
.filter(item => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
return Array.from(new Set(tokens));
}
function splitScopeTokens(value: string): string[] {
const tokens: string[] = [];
let current = "";
let parenDepth = 0;
let bracketDepth = 0;
let braceDepth = 0;
let quote: '"' | "'" | undefined;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (quote) {
current += char;
if (char === quote && value[i - 1] !== "\\") {
quote = undefined;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === "(") {
parenDepth++;
current += char;
continue;
}
if (char === ")") {
parenDepth = Math.max(0, parenDepth - 1);
current += char;
continue;
}
if (char === "[") {
bracketDepth++;
current += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
current += char;
continue;
}
if (char === "{") {
braceDepth++;
current += char;
continue;
}
if (char === "}") {
braceDepth = Math.max(0, braceDepth - 1);
current += char;
continue;
}
if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
const token = current.trim();
if (token.length > 0) {
tokens.push(token);
}
current = "";
continue;
}
current += char;
}
const tail = current.trim();
if (tail.length > 0) {
tokens.push(tail);
}
return tokens;
}
function normalizeScopeField(value: unknown): string[] | undefined {
const normalized = normalizeRuleField(value);
if (!normalized) {
return undefined;
}
const tokens = normalized
.flatMap(splitScopeTokens)
.map(token => {
// Tolerate malformed frontmatter (e.g. `scope: "text","thinking"`) whose
// YAML-fallback parse leaves per-token quotes intact (issue #4796).
const quote = token[0];
if (token.length >= 2 && (quote === '"' || quote === "'") && token[token.length - 1] === quote) {
return token.slice(1, -1).trim();
}
return token;
})
.filter(item => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
return Array.from(new Set(tokens));
}
/**
* Heuristic for condition shorthand that looks like a file glob (for example `*.rs`).
*/
function isLikelyFileGlob(value: string): boolean {
const token = value.trim();
if (token.length === 0) {
return false;
}
if (/[\\^$+|()]/.test(token)) {
return false;
}
if (!/[?*[\]{}]/.test(token)) {
return false;
}
if (token.includes("/")) {
return true;
}
return /^\*\.[^\s/]+$/.test(token);
}
/**
* Parse `condition` + `scope` from rule frontmatter.
*
* - `condition` accepts string or string[]
* - `scope` accepts string or string[]
* - legacy `ttsr_trigger` / `ttsrTrigger` are accepted as a `condition` fallback
* - condition tokens that look like file globs become scope shorthands:
* `*.rs` => `tool:edit(*.rs)`, `tool:write(*.rs)` and a catch-all condition `.*`
* - `astCondition` holds ast-grep patterns and is kept verbatim (no glob inference)
*/
export function parseRuleConditionAndScope(
frontmatter: RuleFrontmatter,
): Pick<Rule, "condition" | "astCondition" | "scope"> {
const rawCondition = frontmatter.condition ?? frontmatter.ttsr_trigger ?? frontmatter.ttsrTrigger;
const parsedCondition = normalizeRuleField(rawCondition);
const astCondition = normalizeRuleField(frontmatter.astCondition);
const parsedScope = normalizeScopeField(frontmatter.scope);
const inferredScope: string[] = [];
const condition: string[] = [];
for (const token of parsedCondition ?? []) {
if (isLikelyFileGlob(token)) {
for (const toolName of CONDITION_GLOB_SCOPE_TOOLS) {
inferredScope.push(`tool:${toolName}(${token})`);
}
continue;
}
condition.push(token);
}
if (condition.length === 0 && inferredScope.length > 0) {
condition.push(".*");
}
const scope = [...(parsedScope ?? []), ...inferredScope];
return {
condition: condition.length > 0 ? Array.from(new Set(condition)) : undefined,
astCondition,
scope: scope.length > 0 ? Array.from(new Set(scope)) : undefined,
};
}
/** Leading PCRE-style inline flag group, e.g. `(?i)` or `(?ims)`. */
const INLINE_FLAG_PREFIX = /^\(\?([a-z]+)\)/;
/** Inline flags that map cleanly onto native `RegExp` flags. */
const TRANSLATABLE_INLINE_FLAGS = /^[ims]+$/;
/**
* Compile a rule `condition` into a `RegExp`, translating a leading PCRE-style
* inline flag group into native `RegExp` flags.
*
* JS/Bun `RegExp` rejects inline flag prefixes such as `(?i)`, so a rule written
* `condition: "(?i)pre.existing"` would otherwise throw at compile time and be
* silently dropped (see issue #4796). Only a *leading* group of `i`/`m`/`s`
* flags is translated; anything else — mid-pattern groups, unsupported flags —
* is passed through verbatim so the native error still surfaces for genuinely
* invalid patterns.
*/
export function compileRuleCondition(pattern: string): RegExp {
const match = INLINE_FLAG_PREFIX.exec(pattern);
if (match && TRANSLATABLE_INLINE_FLAGS.test(match[1])) {
const flags = Array.from(new Set(match[1])).join("");
return new RegExp(pattern.slice(match[0].length), flags);
}
return new RegExp(pattern);
}
let activeRules: readonly Rule[] = [];
/**
* Process-global snapshot of rules the active session loaded.
* Read by internal URL protocol handlers (rule://).
*/
export function getActiveRules(): readonly Rule[] {
return activeRules;
}
/** Replace the active rule snapshot. Called once per top-level session. */
export function setActiveRules(value: readonly Rule[]): void {
activeRules = value;
}
/** Reset the active rule snapshot. Test-only. */
export function resetActiveRulesForTests(): void {
activeRules = [];
}
export const ruleCapability = defineCapability<Rule>({
id: "rules",
displayName: "Rules",
description: "Project-specific rules and constraints (Cursor MDC, Windsurf, Cline formats)",
key: rule => rule.name,
toExtensionId: rule => `rule:${rule.name}`,
validate: rule => {
if (!rule.name) return "Missing rule name";
if (!rule.path) return "Missing rule path";
if (!rule.content || typeof rule.content !== "string") return "Rule must have content";
return undefined;
},
});