import type { Rule as AstGrepRuleDefinition } from '@ast-grep/napi'; import { Context, Effect, FileSystem, Layer, Option, Order, Path, Schema } from 'effect'; import { sort } from 'effect/Array'; import { SKIPPED_FILES } from '../../constants.ts'; import { extractBody, parseFrontmatter } from '../../frontmatter.ts'; import { Pattern } from '../../Pattern.ts'; import { Rule } from '../../Rule.ts'; type PatternLevel = Schema.Schema.Type; const regexOption = Option.liftThrowable((pattern: string) => new RegExp(pattern) ); const emptyEntries: ReadonlyArray = []; const emptyPatterns: ReadonlyArray = []; const readStringArray = ( value: unknown ): Option.Option> => { if (!Array.isArray(value)) { return Option.none(); } const strings = value.flatMap((entry) => typeof entry === 'string' ? [entry] : [] ); return strings.length === value.length ? Option.some(strings) : Option.none(); }; const stringOption = (value: unknown): Option.Option => typeof value === 'string' ? Option.some(value) : Option.none(); const isAstGrepRuleDefinition = ( value: unknown ): value is AstGrepRuleDefinition => typeof value === 'object' && value !== null && !Array.isArray(value); const readAstRuleList = ( value: unknown ): Option.Option> => { if (isAstGrepRuleDefinition(value)) { return Option.some([value]); } if (!Array.isArray(value)) { return Option.none(); } const rules = value.flatMap((entry) => isAstGrepRuleDefinition(entry) ? [entry] : [] ); return rules.length === value.length && rules.length > 0 ? Option.some(rules) : Option.none(); }; const readAstRuleRecord = ( value: unknown ): Option.Option> => { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return Option.none(); } const entries = Object.entries(value); const rules = entries.flatMap(([key, entry]) => isAstGrepRuleDefinition(entry) ? [[key, entry] as const] : [] ); return rules.length === entries.length ? Option.some(Object.fromEntries(rules)) : Option.none(); }; const isSkippedFile = (name: string): boolean => SKIPPED_FILES.some( (prefix) => name.startsWith(prefix) || name.toLowerCase() === `${prefix.toLowerCase()}.md` ); const patternLevel = (value: Option.Option): PatternLevel => Option.match(value, { onNone: () => 'info', onSome: (current) => current === 'critical' || current === 'high' || current === 'medium' || current === 'warning' || current === 'info' ? current : 'info' }); const patternEvent = (value: Option.Option): 'before' | 'after' => Option.match(value, { onNone: () => 'before', onSome: (current) => current.toLowerCase() === 'after' ? 'after' : 'before' }); // AST detectors may specify `pattern: ` or `pattern: [, ...]` // in YAML frontmatter. Regex detectors always use a single string. const readPatternList = ( raw: unknown ): Option.Option> => { if (typeof raw === 'string') { return Option.some([raw]); } return readStringArray(raw); }; const toDetector = ( raw: Record ): Option.Option => { const detector = Option.match(stringOption(raw.detector), { onNone: () => 'regex', onSome: (value) => (value === 'ast' ? 'ast' : 'regex') }); if (detector === 'ast') { const rule = readAstRuleList(raw.rule); const rules = Option.isSome(rule) ? rule : readAstRuleList(raw.rules); if (Option.isSome(rules)) { const constraints = readAstRuleRecord(raw.constraints); return Option.some( new Pattern.AstDetector({ patterns: [], rules: [...rules.value], ...(Option.isSome(constraints) ? { constraints: constraints.value } : undefined) }) ); } const patterns = readPatternList(raw.pattern); if (Option.isNone(patterns) || patterns.value.length === 0) { return Option.none(); } const inside = stringOption(raw.inside); return Option.some( new Pattern.AstDetector({ patterns: patterns.value, ...(Option.isSome(inside) ? { inside: inside.value } : undefined) }) ); } const pattern = stringOption(raw.pattern); if (Option.isNone(pattern)) { return Option.none(); } return Option.isSome(regexOption(pattern.value)) ? Option.some( new Pattern.RegexDetector({ pattern: pattern.value, matchInComments: raw.matchInComments === true || raw.matchInComments === 'true' }) ) : Option.none(); }; const toPattern = ( filePath: string, content: string ): Option.Option => { const raw = parseFrontmatter(content); const name = stringOption(raw.name); const detector = toDetector(raw); const toolRegex = Option.match(stringOption(raw.tool), { onNone: () => '.*', onSome: (value) => value }); if ( Option.isNone(name) || Option.isNone(detector) || Option.isNone(regexOption(toolRegex)) ) { return Option.none(); } const description = Option.match(stringOption(raw.description), { onNone: () => '', onSome: (value) => value }); const glob = stringOption(raw.glob); const ignoreGlob = readStringArray(raw.ignoreGlob); const suggestedSkills = readStringArray(raw.suggestSkills); return Option.some( new Pattern.Value({ name: name.value, description, event: patternEvent(stringOption(raw.event)), toolRegex, level: patternLevel(stringOption(raw.level)), ...(Option.isSome(glob) ? { glob: glob.value } : undefined), ...(Option.isSome(ignoreGlob) ? { ignoreGlob: [...ignoreGlob.value] } : undefined), detector: detector.value, guidance: extractBody(content), ...(Option.isSome(suggestedSkills) ? { suggestedSkills: [...suggestedSkills.value] } : undefined), sourcePath: filePath }) ); }; const patternOrder = Order.mapInput( Order.String, (pattern: Pattern.Value) => pattern.sourcePath ); export const toRuleDefinition = (pattern: Pattern.Value) => new Rule.Definition({ id: `legacy-pattern:${pattern.name}`, description: pattern.description, action: 'injectUserMessage', severity: pattern.level, patternName: pattern.name, sourcePath: pattern.sourcePath }); export const loadPatterns = (patternsDir: string) => Effect.gen(function*() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const readDirectory = (directory: string) => fileSystem.readDirectory(directory).pipe( Effect.catchTag( 'PlatformError', () => Effect.succeed(emptyEntries) ) ); const stat = (target: string) => fileSystem.stat(target).pipe( Effect.map(Option.some), Effect.catchTag( 'PlatformError', () => Effect.succeed(Option.none()) ) ); const readPatternFile = (target: string) => fileSystem.readFileString(target).pipe( Effect.map((content) => toPattern(target, content)), Effect.catchTag( 'PlatformError', () => Effect.succeed(Option.none()) ) ); const walkPatterns = ( directory: string ): Effect.Effect> => Effect.gen(function*() { const entries = yield* readDirectory(directory); const nested: ReadonlyArray> = yield* Effect.forEach( entries, (entry) => Effect.gen(function*() { const fullPath = path.join(directory, entry); const info = yield* stat(fullPath); if (Option.isNone(info)) { return emptyPatterns; } if (info.value.type === 'Directory') { return yield* walkPatterns(fullPath); } if ( info.value.type !== 'File' || !entry.endsWith('.md') || isSkippedFile(entry) ) { return emptyPatterns; } const loaded = yield* readPatternFile(fullPath); return Option.match(loaded, { onNone: () => emptyPatterns, onSome: (pattern) => [pattern] }); }) ); return nested.flatMap((patterns) => patterns); }); return sort(yield* walkPatterns(patternsDir), patternOrder); }); export namespace PatternCatalog { export interface Interface { readonly getPatterns: Effect.Effect>; readonly getRules: Effect.Effect>; } export class Service extends Context.Service()( 'pi-harness-kit/kernel/PatternCatalog' ) {} export const layer = (patternsDir: string) => Layer.effect( Service, Effect.gen(function*() { const patterns = yield* loadPatterns(patternsDir); return Service.of({ getPatterns: Effect.succeed(patterns), getRules: Effect.succeed(patterns.map(toRuleDefinition)) }); }) ); }