Skip to content
Merged
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
25 changes: 25 additions & 0 deletions src/rules/S001-required-sections.ts
Original file line number Diff line number Diff line change
@@ -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}"`,
}));
},
};
39 changes: 39 additions & 0 deletions src/rules/S003-no-secrets.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
7 changes: 6 additions & 1 deletion src/rules/index.ts
Original file line number Diff line number Diff line change
@@ -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,
];
16 changes: 16 additions & 0 deletions tests/fixtures/invalid-claude.md
Original file line number Diff line number Diff line change
@@ -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(*:*)
22 changes: 22 additions & 0 deletions tests/fixtures/valid-claude.md
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions tests/rules/S001.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
74 changes: 74 additions & 0 deletions tests/rules/S003.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading