/** * Saved-rules library for pi-ast-grep. * * The /ast-grep-rules command lets the model persist named YAML rules in the * session via pi.appendEntry (custom entries, not in model context) and load * them back by scanning the current branch. This module is pure: parsing, * validation, and mutation all work on plain data so the command handler * stays trivially testable. */ import type { SessionEntry } from "@earendil-works/pi-coding-agent"; /** name → full YAML rule text, persisted as the data of an appendEntry entry. */ export interface SavedRules { rules: Record; } /** customType for the appendEntry entries that carry SavedRules state. */ export const RULES_ENTRY_TYPE = "ast-grep-rules"; /** * Minimal session-manager surface needed to read persisted entries. * Structurally compatible with ctx.sessionManager (ReadonlySessionManager), * which is not exported from the pi-coding-agent package index. */ export interface SessionBranchReader { getBranch: () => SessionEntry[]; } /** Usage text replied by /ast-grep-rules for empty or unknown subcommands. */ export const RULES_USAGE = [ "usage: /ast-grep-rules ", " save save or replace a named rule (YAML must contain an id: line)", " list list saved rules with the first line of each", " get show the full YAML of a saved rule", " delete remove a saved rule", " validate check a saved rule against ast-grep", ].join("\n"); export type RulesOp = "save" | "list" | "get" | "delete" | "validate" | "help"; export interface ParsedRulesCommand { name?: string; op: RulesOp; yaml?: string; } const RULE_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; const RULE_ID_LINE_PATTERN = /^\s*id\s*:/m; const WHITESPACE_PATTERN = /\s+/; const RULE_LANGUAGE_PATTERN = /^\s*language\s*:\s*([A-Za-z0-9_+-]+)/m; const LINE_BREAK_PATTERN = /\r?\n/; /** Rule names are plain identifiers, 1–64 chars of letters, digits, _ and -. */ export function validRuleName(name: string): boolean { return RULE_NAME_PATTERN.test(name); } /** YAML is acceptable when non-empty and it declares an id: on some line. */ export function validRuleYaml(yaml: string): boolean { const trimmed = yaml.trim(); return trimmed !== "" && RULE_ID_LINE_PATTERN.test(trimmed); } /** * Parses the raw argument string of /ast-grep-rules. The yaml payload of * save is the rest of the line verbatim (interior spacing preserved). */ export function parseRulesCommand(args: string): ParsedRulesCommand { const trimmed = args.trim(); if (trimmed === "") { return { op: "help" }; } const spaceIndex = trimmed.indexOf(" "); const op = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); const rest = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex + 1).trim(); switch (op) { case "list": return rest === "" ? { op: "list" } : { op: "help" }; case "get": case "delete": case "validate": { const name = rest.split(WHITESPACE_PATTERN)[0] ?? ""; return name === "" ? { op: "help" } : { op, name }; } case "save": { const name = rest.split(WHITESPACE_PATTERN)[0] ?? ""; if (name === "") { return { op: "help" }; } const yaml = rest.slice(name.length).trim(); return yaml === "" ? { op: "help" } : { op: "save", name, yaml }; } default: return { op: "help" }; } } /** * Returns the first `language:` value declared in the rule YAML, or undefined * when the YAML has no language line. The language field picks the parser * ast-grep applies to a rule, so `validate` needs it to choose a fixture * extension and scan with the right parser. */ export function ruleLanguage(yaml: string): string | undefined { const match = RULE_LANGUAGE_PATTERN.exec(yaml); return match?.[1]?.trim(); } /** First non-empty line of YAML, trimmed and capped at 80 characters. */ function previewLine(yaml: string): string { for (const line of yaml.split(LINE_BREAK_PATTERN)) { const trimmed = line.trim(); if (trimmed !== "") { return trimmed.slice(0, 80); } } return ""; } /** * Pure mutation over the saved-rules state. On failure the returned `next` * is the same reference as `current`, so callers can skip persisting by * reference identity. The `message` is what the command replies with. * * validate is deliberately not part of the mutation domain: the command * handler short-circuits it before reaching this function. */ export function applyRulesMutation( current: SavedRules, op: Exclude, name?: string, yaml?: string, ): { next: SavedRules; message: string } { switch (op) { case "save": { if (name === undefined || !validRuleName(name)) { return { next: current, message: `invalid rule name: ${name ?? ""}` }; } if (yaml === undefined || !validRuleYaml(yaml)) { return { next: current, message: `invalid rule YAML for ${name}: must be non-empty and contain an id: line`, }; } return { next: { rules: { ...current.rules, [name]: yaml } }, message: `saved rule ${name}`, }; } case "list": { // Tombstoned names ("") read as deleted everywhere. const names = Object.keys(current.rules) .filter((ruleName) => current.rules[ruleName] !== "") .sort(); if (names.length === 0) { return { next: current, message: "no saved rules" }; } const lines = names.map((ruleName) => `${ruleName} — ${previewLine(current.rules[ruleName] ?? "")}`); return { next: current, message: lines.join("\n") }; } case "get": { if (name === undefined) { return { next: current, message: "no saved rule" }; } const yamlText = current.rules[name]; if (yamlText === undefined || yamlText === "") { return { next: current, message: `no saved rule ${name}` }; } return { next: current, message: yamlText }; } case "delete": { if (name === undefined) { return { next: current, message: "no saved rule" }; } const yamlText = current.rules[name]; if (yamlText === undefined) { return { next: current, message: `no saved rule ${name}` }; } // Tombstone the name with an empty string instead of removing the key: // loadRules merges later entries over earlier ones and cannot unset a // key, so a persisted delete must carry the deletion forward. The fresh // object keeps the `next !== current` persistence check working. return { next: { rules: { ...current.rules, [name]: "" } }, message: `deleted rule ${name}` }; } default: // Unknown ops (including "help") degrade to usage, matching // parseRulesCommand's handling of unrecognized subcommands. return { next: current, message: RULES_USAGE }; } } /** * Loads the saved rules from the current branch: every custom entry with * our customType contributes its rules, with later entries (closer to the * leaf) winning on name collisions. Empty-string values are delete * tombstones and drop the name from the result. Non-object entry data is * skipped. */ export function loadRules(sessionManager: SessionBranchReader): SavedRules { const merged: SavedRules = { rules: {} }; for (const entry of sessionManager.getBranch()) { if (entry.type !== "custom" || entry.customType !== RULES_ENTRY_TYPE) { continue; } const data = entry.data; if (typeof data !== "object" || data === null || Array.isArray(data)) { continue; } const incoming = (data as Partial).rules; if (typeof incoming !== "object" || incoming === null || Array.isArray(incoming)) { continue; } for (const [ruleName, ruleYaml] of Object.entries(incoming)) { merged.rules[ruleName] = ruleYaml; } } // Deletes persist as empty-string tombstones because a later-wins merge // cannot remove a key that an earlier entry introduced. Strip them here: // any rule whose final value is exactly "" is deleted. A later save of the // same name overwrites the tombstone and survives the strip. for (const ruleName of Object.keys(merged.rules)) { if (merged.rules[ruleName] === "") { delete merged.rules[ruleName]; } } return merged; }