From 3c663bcc3e49793c82dd6462607f475902ed7eff Mon Sep 17 00:00:00 2001 From: mrwogu Date: Thu, 19 Mar 2026 16:49:27 +0100 Subject: [PATCH] feat(validator): add per-file rule exclusions via fileExcludes config New `fileExcludes` option in `ValidatorConfig` and `promptscript.yaml` allows disabling specific rules for files matching glob patterns. Uses picomatch for glob matching. Example: validation: fileExcludes: - pattern: "skills/**/SKILL.md" rules: [PS011] --- docs/guides/security.md | 8 +- docs/reference/config.md | 32 ++++++-- packages/core/src/types/config.ts | 7 ++ packages/validator/package.json | 6 +- .../src/__tests__/standalone.spec.ts | 73 +++++++++++++++++++ packages/validator/src/index.ts | 1 + packages/validator/src/types.ts | 14 ++++ packages/validator/src/validator.ts | 34 ++++++++- pnpm-lock.yaml | 12 +++ schema/config.json | 25 +++++++ 10 files changed, 204 insertions(+), 8 deletions(-) diff --git a/docs/guides/security.md b/docs/guides/security.md index 8979aa9721..d35bf521c2 100644 --- a/docs/guides/security.md +++ b/docs/guides/security.md @@ -316,7 +316,13 @@ const validator = createValidator({ ### Limitations - **New attack patterns**: Attackers constantly evolve techniques. Keep PromptScript updated. -- **Context-dependent**: Some patterns may cause false positives in legitimate security documentation. +- **Context-dependent**: Some patterns may cause false positives in legitimate security documentation. Use `fileExcludes` to suppress specific rules for known-safe files: + ```yaml + validation: + fileExcludes: + - pattern: 'skills/**/SKILL.md' + rules: [PS011] + ``` - **Language coverage**: Not all languages are covered. Add custom patterns for unsupported languages. ## Environment Variables vs Template Variables diff --git a/docs/reference/config.md b/docs/reference/config.md index 2e7033894d..6b20844c9f 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -498,13 +498,35 @@ validation: - 'unused-shortcut' rules: require-syntax: error + fileExcludes: + - pattern: 'skills/**/SKILL.md' + rules: [PS011] ``` -| Field | Type | Default | Description | -| ---------------- | -------- | ------- | ----------------------- | -| `strict` | boolean | `false` | Warnings as errors | -| `ignoreWarnings` | string[] | `[]` | Warning codes to ignore | -| `rules` | object | `{}` | Rule severity overrides | +| Field | Type | Default | Description | +| ---------------- | ------------- | ------- | ------------------------------------ | +| `strict` | boolean | `false` | Warnings as errors | +| `ignoreWarnings` | string[] | `[]` | Warning codes to ignore | +| `rules` | object | `{}` | Rule severity overrides | +| `fileExcludes` | FileExclude[] | `[]` | Per-file rule exclusions (see below) | + +#### fileExcludes + +Disable specific rules for files matching a glob pattern. Each entry has: + +| Field | Type | Description | +| --------- | -------- | ----------------------------------------------- | +| `pattern` | string | Glob pattern matched against the file path | +| `rules` | string[] | Rule names or IDs to disable for matching files | + +```yaml +validation: + fileExcludes: + - pattern: 'skills/**/SKILL.md' + rules: [PS011, PS012] + - pattern: 'vendor/**' + rules: [empty-block] +``` ### watch diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 2871687d9e..dde513423e 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -273,6 +273,13 @@ export interface PromptScriptConfig { validation?: { requiredGuards?: string[]; rules?: Record; + /** Per-file rule exclusions using glob patterns */ + fileExcludes?: Array<{ + /** Glob pattern matched against the file path */ + pattern: string; + /** Rule names or IDs to disable for matching files */ + rules: string[]; + }>; }; } diff --git a/packages/validator/package.json b/packages/validator/package.json index 9596cb8f39..39357bbee1 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -25,7 +25,11 @@ "main": "./src/index.ts", "types": "./src/index.d.ts", "dependencies": { + "@promptscript/core": "workspace:^", "@swc/helpers": "~0.5.19", - "@promptscript/core": "workspace:^" + "picomatch": "^4.0.3" + }, + "devDependencies": { + "@types/picomatch": "^4.0.2" } } diff --git a/packages/validator/src/__tests__/standalone.spec.ts b/packages/validator/src/__tests__/standalone.spec.ts index 10921058f9..98de6db6f8 100644 --- a/packages/validator/src/__tests__/standalone.spec.ts +++ b/packages/validator/src/__tests__/standalone.spec.ts @@ -150,6 +150,79 @@ describe('Validator with disableRules config', () => { }); }); +describe('Validator with fileExcludes config', () => { + it('should skip rules for files matching a glob pattern by rule name', () => { + const validator = new Validator({ + fileExcludes: [{ pattern: 'skills/**/SKILL.md', rules: ['required-meta-id'] }], + }); + const ast = createTestProgram({ + loc: { file: 'skills/using-superpowers/SKILL.md', line: 1, column: 1 }, + meta: undefined, + }); + + const result = validator.validate(ast); + + expect(result.errors.some((e) => e.ruleId === 'PS001')).toBe(false); + }); + + it('should skip rules for files matching a glob pattern by rule ID', () => { + const validator = new Validator({ + fileExcludes: [{ pattern: 'skills/**', rules: ['PS001'] }], + }); + const ast = createTestProgram({ + loc: { file: 'skills/debug/SKILL.md', line: 1, column: 1 }, + meta: undefined, + }); + + const result = validator.validate(ast); + + expect(result.errors.some((e) => e.ruleId === 'PS001')).toBe(false); + }); + + it('should not skip rules for files that do not match the pattern', () => { + const validator = new Validator({ + fileExcludes: [{ pattern: 'skills/**', rules: ['required-meta-id'] }], + }); + const ast = createTestProgram({ + loc: { file: 'project.prs', line: 1, column: 1 }, + meta: undefined, + }); + + const result = validator.validate(ast); + + expect(result.errors.some((e) => e.ruleId === 'PS001')).toBe(true); + }); + + it('should support multiple fileExclude entries', () => { + const validator = new Validator({ + fileExcludes: [ + { pattern: 'skills/**', rules: ['PS001'] }, + { pattern: 'vendor/**', rules: ['PS002'] }, + ], + }); + const ast = createTestProgram({ + loc: { file: 'vendor/third-party.prs', line: 1, column: 1 }, + meta: undefined, + }); + + const result = validator.validate(ast); + + // PS002 should be excluded for vendor files + expect(result.errors.some((e) => e.ruleId === 'PS002')).toBe(false); + // PS001 should still fire (not excluded for vendor) + expect(result.errors.some((e) => e.ruleId === 'PS001')).toBe(true); + }); + + it('should handle empty fileExcludes gracefully', () => { + const validator = new Validator({ fileExcludes: [] }); + const ast = createTestProgram({ meta: undefined }); + + const result = validator.validate(ast); + + expect(result.errors.some((e) => e.ruleId === 'PS001')).toBe(true); + }); +}); + describe('formatValidationMessage', () => { const baseMessage: ValidationMessage = { ruleId: 'PS001', diff --git a/packages/validator/src/index.ts b/packages/validator/src/index.ts index e202b973bc..ac1655b8fc 100644 --- a/packages/validator/src/index.ts +++ b/packages/validator/src/index.ts @@ -23,6 +23,7 @@ export type { ValidationRule, ValidatorConfig, ValidateOptions, + FileExclude, } from './types.js'; // Rules diff --git a/packages/validator/src/types.ts b/packages/validator/src/types.ts index f1dc321a03..0563863660 100644 --- a/packages/validator/src/types.ts +++ b/packages/validator/src/types.ts @@ -70,6 +70,18 @@ export interface ValidationRule { /** * Validator configuration options. */ +/** + * Per-file rule exclusion entry. + * + * Allows disabling specific rules for files matching a glob pattern. + */ +export interface FileExclude { + /** Glob pattern matched against the file path (e.g., "skills/** /SKILL.md") */ + pattern: string; + /** Rule names or IDs to disable for matching files */ + rules: string[]; +} + export interface ValidatorConfig { /** Override severity for specific rules (rule name -> severity or 'off') */ rules?: Record; @@ -79,6 +91,8 @@ export interface ValidatorConfig { blockedPatterns?: (string | RegExp)[]; /** Array of rule names to disable */ disableRules?: string[]; + /** Per-file rule exclusions using glob patterns */ + fileExcludes?: FileExclude[]; /** Custom validation rules to add */ customRules?: ValidationRule[]; /** Logger for verbose/debug output */ diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index 492cddb363..10f3b915fb 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -1,4 +1,5 @@ import { noopLogger, type Logger, type Program } from '@promptscript/core'; +import picomatch from 'picomatch'; import type { ValidationRule, ValidatorConfig, @@ -66,8 +67,16 @@ export class Validator { */ validate(ast: Program): ValidationResult { const messages: ValidationMessage[] = []; + + // Compute per-file excluded rules + const fileExcludedRules = this.getFileExcludedRules(ast.loc.file); + const activeRules = this.rules.filter( - (r) => !this.disabledRules.has(r.name) && !this.disabledRules.has(r.id) + (r) => + !this.disabledRules.has(r.name) && + !this.disabledRules.has(r.id) && + !fileExcludedRules.has(r.name) && + !fileExcludedRules.has(r.id) ); this.logger.verbose(`Running ${activeRules.length} validation rules`); @@ -79,6 +88,12 @@ export class Validator { continue; } + // Skip if rule is excluded for this file + if (fileExcludedRules.has(rule.name) || fileExcludedRules.has(rule.id)) { + this.logger.debug(`Skipping file-excluded rule: ${rule.name} (file: ${ast.loc.file})`); + continue; + } + // Determine the severity for this rule const configuredSeverity = this.config.rules?.[rule.name]; const severity: Severity | 'off' = configuredSeverity ?? rule.defaultSeverity; @@ -151,6 +166,23 @@ export class Validator { return false; } + /** + * Compute rules excluded for a specific file based on fileExcludes config. + */ + private getFileExcludedRules(filePath: string): Set { + const excluded = new Set(); + if (!this.config.fileExcludes || !filePath) return excluded; + + for (const entry of this.config.fileExcludes) { + if (picomatch.isMatch(filePath, entry.pattern, { contains: true })) { + for (const rule of entry.rules) { + excluded.add(rule); + } + } + } + return excluded; + } + /** * Get the current configuration. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3bdd07ae2..b6057823ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,6 +365,13 @@ importers: '@swc/helpers': specifier: ~0.5.19 version: 0.5.19 + picomatch: + specifier: ^4.0.3 + version: 4.0.3 + devDependencies: + '@types/picomatch': + specifier: ^4.0.2 + version: 4.0.2 packages: @@ -2477,6 +2484,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/picomatch@4.0.2': + resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -7864,6 +7874,8 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/picomatch@4.0.2': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 diff --git a/schema/config.json b/schema/config.json index 2ce5f9dc17..7264e6104d 100644 --- a/schema/config.json +++ b/schema/config.json @@ -266,6 +266,31 @@ "off" ] } + }, + "fileExcludes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern matched against the file path" + }, + "rules": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Rule names or IDs to disable for matching files" + } + }, + "required": [ + "pattern", + "rules" + ], + "additionalProperties": false + }, + "description": "Per-file rule exclusions using glob patterns" } }, "additionalProperties": false,