Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/guides/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 27 additions & 5 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ export interface PromptScriptConfig {
validation?: {
requiredGuards?: string[];
rules?: Record<string, 'error' | 'warning' | 'off'>;
/** 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[];
}>;
};
}

Expand Down
6 changes: 5 additions & 1 deletion packages/validator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
73 changes: 73 additions & 0 deletions packages/validator/src/__tests__/standalone.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/validator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type {
ValidationRule,
ValidatorConfig,
ValidateOptions,
FileExclude,
} from './types.js';

// Rules
Expand Down
14 changes: 14 additions & 0 deletions packages/validator/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Severity | 'off'>;
Expand All @@ -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 */
Expand Down
34 changes: 33 additions & 1 deletion packages/validator/src/validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { noopLogger, type Logger, type Program } from '@promptscript/core';
import picomatch from 'picomatch';
import type {
ValidationRule,
ValidatorConfig,
Expand Down Expand Up @@ -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`);
Expand All @@ -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;
Expand Down Expand Up @@ -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<string> {
const excluded = new Set<string>();
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.
*/
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions schema/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading