From b4bc38f3afeb25e820f1c7da0e384c56e7b92973 Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Wed, 1 Apr 2026 12:11:08 +0200 Subject: [PATCH 1/3] feat: add S001 required-sections rule Checks that spec files contain the three required sections: Project Overview, Constraints, and Acceptance Criteria. Case-insensitive matching, works with any heading level. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/rules/S001-required-sections.ts | 25 +++++++++++ tests/rules/S001.test.ts | 65 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/rules/S001-required-sections.ts create mode 100644 tests/rules/S001.test.ts diff --git a/src/rules/S001-required-sections.ts b/src/rules/S001-required-sections.ts new file mode 100644 index 0000000..b0302dc --- /dev/null +++ b/src/rules/S001-required-sections.ts @@ -0,0 +1,25 @@ +import { Rule, LintResult, ParsedSpecFile } from '../types.js'; + +const REQUIRED_SECTIONS = [ + 'Project Overview', + 'Constraints', + 'Acceptance Criteria', +]; + +export const requiredSectionsRule: Rule = { + id: 'S001', + name: 'required-sections', + severity: 'error', + description: 'Spec files must contain required sections: Project Overview, Constraints, Acceptance Criteria', + check(file: ParsedSpecFile): LintResult[] { + const headings = file.sections.map((s) => s.heading.toLowerCase()); + + return REQUIRED_SECTIONS + .filter((required) => !headings.includes(required.toLowerCase())) + .map((missing) => ({ + ruleId: 'S001', + severity: 'error' as const, + message: `Missing required section: "${missing}"`, + })); + }, +}; diff --git a/tests/rules/S001.test.ts b/tests/rules/S001.test.ts new file mode 100644 index 0000000..65dce63 --- /dev/null +++ b/tests/rules/S001.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { requiredSectionsRule } from '../../src/rules/S001-required-sections.js'; +import { parseSpecFile } from '../../src/parser.js'; + +describe('S001 required-sections', () => { + const check = (content: string) => { + const file = parseSpecFile('/test.md', content); + return requiredSectionsRule.check(file); + }; + + it('passes when all required sections are present', () => { + const content = '## Project Overview\n\nOverview\n\n## Constraints\n\nConstraints\n\n## Acceptance Criteria\n\nCriteria'; + expect(check(content)).toHaveLength(0); + }); + + it('passes with different heading levels for required sections', () => { + const content = '# Project Overview\n\n## Constraints\n\n### Acceptance Criteria'; + expect(check(content)).toHaveLength(0); + }); + + it('passes when required sections exist among other sections', () => { + const content = '## Intro\n\n## Project Overview\n\n## Stack\n\n## Constraints\n\n## Notes\n\n## Acceptance Criteria'; + expect(check(content)).toHaveLength(0); + }); + + it('fails when Project Overview is missing', () => { + const content = '## Constraints\n\n## Acceptance Criteria'; + const results = check(content); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results.some((r) => r.message.includes('Project Overview'))).toBe(true); + }); + + it('fails when Constraints is missing', () => { + const content = '## Project Overview\n\n## Acceptance Criteria'; + const results = check(content); + expect(results.some((r) => r.message.includes('Constraints'))).toBe(true); + }); + + it('fails when Acceptance Criteria is missing', () => { + const content = '## Project Overview\n\n## Constraints'; + const results = check(content); + expect(results.some((r) => r.message.includes('Acceptance Criteria'))).toBe(true); + }); + + it('fails when all required sections are missing', () => { + const content = '## Random Section\n\nSome text'; + const results = check(content); + expect(results).toHaveLength(3); + }); + + it('fails on empty file', () => { + const results = check(''); + expect(results).toHaveLength(3); + }); + + it('is case-insensitive for section matching', () => { + const content = '## project overview\n\n## constraints\n\n## acceptance criteria'; + expect(check(content)).toHaveLength(0); + }); + + it('has correct rule metadata', () => { + expect(requiredSectionsRule.id).toBe('S001'); + expect(requiredSectionsRule.severity).toBe('error'); + }); +}); From 379d6c9e427261bb521fc4bca4eedd06609d2c6f Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Wed, 1 Apr 2026 12:13:26 +0200 Subject: [PATCH 2/3] feat: add S003 no-secrets rule Detects accidentally committed secrets in spec files including OpenAI/Anthropic API keys, GitHub tokens, AWS access keys, private key blocks, and Slack tokens. Minimum length thresholds prevent false positives on prose mentions of key prefixes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/rules/S003-no-secrets.ts | 39 +++++++++++++++++++ tests/rules/S003.test.ts | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/rules/S003-no-secrets.ts create mode 100644 tests/rules/S003.test.ts diff --git a/src/rules/S003-no-secrets.ts b/src/rules/S003-no-secrets.ts new file mode 100644 index 0000000..ec7e236 --- /dev/null +++ b/src/rules/S003-no-secrets.ts @@ -0,0 +1,39 @@ +import { Rule, LintResult, ParsedSpecFile } from '../types.js'; + +const SECRET_PATTERNS: { pattern: RegExp; label: string }[] = [ + { pattern: /sk-[a-zA-Z0-9_-]{20,}/, label: 'OpenAI/Anthropic API key' }, + { pattern: /ghp_[a-zA-Z0-9]{36}/, label: 'GitHub personal access token' }, + { pattern: /gho_[a-zA-Z0-9]{36}/, label: 'GitHub OAuth token' }, + { pattern: /ghs_[a-zA-Z0-9]{36}/, label: 'GitHub server token' }, + { pattern: /AKIA[0-9A-Z]{16}/, label: 'AWS access key' }, + { pattern: /-----BEGIN\s+\w*\s*PRIVATE KEY-----/, label: 'Private key' }, + { pattern: /xoxb-[0-9]{10,}-[a-zA-Z0-9-]+/, label: 'Slack bot token' }, + { pattern: /xoxp-[0-9]{10,}-[a-zA-Z0-9-]+/, label: 'Slack user token' }, +]; + +export const noSecretsRule: Rule = { + id: 'S003', + name: 'no-secrets', + severity: 'error', + description: 'Spec files must not contain secrets, API keys, or credentials', + check(file: ParsedSpecFile): LintResult[] { + const results: LintResult[] = []; + const lines = file.raw.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const { pattern, label } of SECRET_PATTERNS) { + if (pattern.test(line)) { + results.push({ + ruleId: 'S003', + severity: 'error', + message: `Possible ${label} detected. Remove before committing.`, + line: i + 1, + }); + } + } + } + + return results; + }, +}; diff --git a/tests/rules/S003.test.ts b/tests/rules/S003.test.ts new file mode 100644 index 0000000..9ebdd6d --- /dev/null +++ b/tests/rules/S003.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { noSecretsRule } from '../../src/rules/S003-no-secrets.js'; +import { parseSpecFile } from '../../src/parser.js'; + +describe('S003 no-secrets', () => { + const check = (content: string) => { + const file = parseSpecFile('/test.md', content); + return noSecretsRule.check(file); + }; + + it('passes a file with no credentials', () => { + const content = '## Project Overview\n\nThis is a normal spec file with no secrets.'; + expect(check(content)).toHaveLength(0); + }); + + it('passes a file that mentions key patterns in prose without actual keys', () => { + const content = '## Constraints\n\nDo not commit API keys. Use environment variables for sk- prefixed tokens.'; + expect(check(content)).toHaveLength(0); + }); + + it('passes a file with short strings that look like prefixes but are not keys', () => { + const content = '## Notes\n\nThe sk-prefix is used by OpenAI.'; + expect(check(content)).toHaveLength(0); + }); + + it('catches an OpenAI API key', () => { + const content = '## Config\n\nOPENAI_API_KEY=sk-1234567890abcdefghij1234567890abcdefghij12345678'; + const results = check(content); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].severity).toBe('error'); + expect(results[0].ruleId).toBe('S003'); + }); + + it('catches a GitHub personal access token', () => { + const content = '## Auth\n\ntoken: ghp_abcdefghijklmnopqrstuvwxyz1234567890'; + const results = check(content); + expect(results.length).toBeGreaterThanOrEqual(1); + }); + + it('catches an AWS access key', () => { + const content = '## Infra\n\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE'; + const results = check(content); + expect(results.length).toBeGreaterThanOrEqual(1); + }); + + it('catches a private key block', () => { + const content = '## Keys\n\n-----BEGIN RSA PRIVATE KEY-----\nMIIBogIBAAJ'; + const results = check(content); + expect(results.length).toBeGreaterThanOrEqual(1); + }); + + it('catches an Anthropic API key pattern', () => { + const content = '## Config\n\nsk-ant-api03-abcdefghijklmnopqrstuvwxyz123456'; + const results = check(content); + expect(results.length).toBeGreaterThanOrEqual(1); + }); + + it('reports the correct line number', () => { + const content = 'Line 1\nLine 2\nsk-1234567890abcdefghij1234567890abcdefghij12345678\nLine 4'; + const results = check(content); + expect(results[0].line).toBe(3); + }); + + it('catches multiple secrets on different lines', () => { + const content = 'sk-1234567890abcdefghij1234567890abcdefghij12345678\n\nghp_abcdefghijklmnopqrstuvwxyz1234567890'; + const results = check(content); + expect(results.length).toBeGreaterThanOrEqual(2); + }); + + it('has correct rule metadata', () => { + expect(noSecretsRule.id).toBe('S003'); + expect(noSecretsRule.severity).toBe('error'); + }); +}); From 1a465bde1a987d2c04382fd16529a5fb83cd73fb Mon Sep 17 00:00:00 2001 From: hermanngeorge15 Date: Wed, 1 Apr 2026 12:14:54 +0200 Subject: [PATCH 3/3] test: add fixtures and register S001 + S003 rules Co-Authored-By: Claude Opus 4.6 (1M context) --- src/rules/index.ts | 7 ++++++- tests/fixtures/invalid-claude.md | 16 ++++++++++++++++ tests/fixtures/valid-claude.md | 22 ++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/invalid-claude.md create mode 100644 tests/fixtures/valid-claude.md diff --git a/src/rules/index.ts b/src/rules/index.ts index c969af7..eb01934 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,3 +1,8 @@ import { Rule } from '../types.js'; +import { requiredSectionsRule } from './S001-required-sections.js'; +import { noSecretsRule } from './S003-no-secrets.js'; -export const allRules: Rule[] = []; +export const allRules: Rule[] = [ + requiredSectionsRule, + noSecretsRule, +]; diff --git a/tests/fixtures/invalid-claude.md b/tests/fixtures/invalid-claude.md new file mode 100644 index 0000000..6917202 --- /dev/null +++ b/tests/fixtures/invalid-claude.md @@ -0,0 +1,16 @@ +# Bad Project Spec + +## Random Section + +This spec has no required sections. + +## Config + +API_KEY=sk-1234567890abcdefghij1234567890abcdefghij12345678 +GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz1234567890 + +## Random Section + +This is a duplicate heading. + +Bash(*:*) diff --git a/tests/fixtures/valid-claude.md b/tests/fixtures/valid-claude.md new file mode 100644 index 0000000..9f40280 --- /dev/null +++ b/tests/fixtures/valid-claude.md @@ -0,0 +1,22 @@ +# My Project + +## Project Overview + +This project builds a web API for task management. + +## Constraints + +- Node.js >= 18 +- TypeScript strict mode +- All endpoints must return JSON + +## Acceptance Criteria + +- [ ] GET /tasks returns a list of tasks +- [ ] POST /tasks creates a new task +- [ ] Tests pass on CI + +## Tech Stack + +- Express.js +- PostgreSQL