feat: AI Code Governance CLI v1.0.0
- Config-driven gates via .codegov.yml/.codegov.json - disclosure gate: requires AI-GENERATED/AI-ASSISTED markers on changed files - rfc gate: requires issue/RFC reference in commit message or PR description - checklist gate: requires all items checked in REQUIREMENTS.md - quality gate: runs configurable lint/test/typecheck commands - CLI with readable reports and non-zero exit on violations - 41 tests covering all gates, config loading, and integration - Zero skeletons, no fake features, deferred items honestly documented
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { loadConfig, findConfigFile } from '../src/config';
|
||||
|
||||
const TMP = resolve(__dirname, 'tmp-config');
|
||||
|
||||
function clean() {
|
||||
try {
|
||||
rmSync(TMP, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
describe('config', () => {
|
||||
beforeEach(() => {
|
||||
clean();
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clean();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('finds .codegov.yml', () => {
|
||||
writeFileSync(resolve(TMP, '.codegov.yml'), 'enabled: true\n');
|
||||
expect(findConfigFile(TMP)).toBe(resolve(TMP, '.codegov.yml'));
|
||||
});
|
||||
|
||||
it('finds .codegov.json', () => {
|
||||
writeFileSync(resolve(TMP, '.codegov.json'), '{"enabled":true}');
|
||||
expect(findConfigFile(TMP)).toBe(resolve(TMP, '.codegov.json'));
|
||||
});
|
||||
|
||||
it('returns null when no config exists', () => {
|
||||
expect(findConfigFile(TMP)).toBeNull();
|
||||
});
|
||||
|
||||
it('loads JSON config', () => {
|
||||
writeFileSync(resolve(TMP, '.codegov.json'), JSON.stringify({ enabled: true, gates: { rfc: { required: false } } }));
|
||||
const { config, usingDefaults } = loadConfig(undefined, TMP);
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.gates.rfc?.required).toBe(false);
|
||||
expect(usingDefaults).toBe(false);
|
||||
});
|
||||
|
||||
it('loads YAML config', () => {
|
||||
writeFileSync(resolve(TMP, '.codegov.yml'), 'enabled: true\ngates:\n disclosure:\n required: false\n');
|
||||
const { config, usingDefaults } = loadConfig(undefined, TMP);
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.gates.disclosure?.required).toBe(false);
|
||||
expect(usingDefaults).toBe(false);
|
||||
});
|
||||
|
||||
it('uses defaults when no config exists', () => {
|
||||
const { config, usingDefaults } = loadConfig(undefined, TMP);
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.gates.disclosure?.required).toBe(true);
|
||||
expect(usingDefaults).toBe(true);
|
||||
});
|
||||
|
||||
it('merges partial config with defaults', () => {
|
||||
writeFileSync(resolve(TMP, '.codegov.json'), JSON.stringify({ enabled: false }));
|
||||
const { config } = loadConfig(undefined, TMP);
|
||||
expect(config.enabled).toBe(false);
|
||||
expect(config.gates.checklist?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on missing explicit config path', () => {
|
||||
expect(() => loadConfig('/nonexistent/.codegov.yml', TMP)).toThrow('Config file not found');
|
||||
});
|
||||
|
||||
it('throws on invalid config', () => {
|
||||
writeFileSync(resolve(TMP, '.codegov.json'), 'not-json');
|
||||
expect(() => loadConfig(undefined, TMP)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { runDisclosureGate } from '../src/gates/disclosure';
|
||||
import { runRfcGate } from '../src/gates/rfc';
|
||||
import { runChecklistGate } from '../src/gates/checklist';
|
||||
import { runQualityGate } from '../src/gates/quality';
|
||||
import * as gitModule from '../src/git';
|
||||
|
||||
const TMP = resolve(__dirname, 'tmp-gates');
|
||||
|
||||
function clean() {
|
||||
try { rmSync(TMP, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
describe('disclosure gate', () => {
|
||||
beforeEach(() => { clean(); mkdirSync(TMP, { recursive: true }); });
|
||||
afterEach(() => { clean(); });
|
||||
|
||||
it('passes when all files have marker', () => {
|
||||
writeFileSync(resolve(TMP, 'a.ts'), '// AI-GENERATED: this file');
|
||||
writeFileSync(resolve(TMP, 'b.ts'), '/* AI-ASSISTED */');
|
||||
const r = runDisclosureGate([resolve(TMP, 'a.ts'), resolve(TMP, 'b.ts')], {});
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when a file lacks marker', () => {
|
||||
writeFileSync(resolve(TMP, 'a.ts'), '// AI-GENERATED: this file');
|
||||
writeFileSync(resolve(TMP, 'b.ts'), 'no marker here');
|
||||
const r = runDisclosureGate([resolve(TMP, 'a.ts'), resolve(TMP, 'b.ts')], {});
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.details?.length).toBe(1);
|
||||
expect(r.details?.[0]).toContain('b.ts');
|
||||
});
|
||||
|
||||
it('passes when no files to check (all excluded)', () => {
|
||||
const r = runDisclosureGate([], { excludePaths: ['node_modules/'] });
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes configured paths', () => {
|
||||
mkdirSync(resolve(TMP, 'node_modules'), { recursive: true });
|
||||
mkdirSync(resolve(TMP, 'src'), { recursive: true });
|
||||
writeFileSync(resolve(TMP, 'node_modules/x.js'), 'no marker');
|
||||
writeFileSync(resolve(TMP, 'src/app.ts'), '// AI-GENERATED');
|
||||
const r = runDisclosureGate([resolve(TMP, 'node_modules/x.js'), resolve(TMP, 'src/app.ts')], { excludePaths: ['node_modules/'] });
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rfc gate', () => {
|
||||
beforeEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
it('passes when commit message has issue ref', () => {
|
||||
vi.spyOn(gitModule, 'getCommitMessage').mockReturnValue('Fix bug #123');
|
||||
const r = runRfcGate({ source: 'commit-message' });
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when commit message lacks issue ref', () => {
|
||||
vi.spyOn(gitModule, 'getCommitMessage').mockReturnValue('Fix bug');
|
||||
const r = runRfcGate({ source: 'commit-message' });
|
||||
expect(r.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('passes when env var matches pattern', () => {
|
||||
process.env.CODEGOV_RFC_REF = 'TICKET-42';
|
||||
const r = runRfcGate({ source: 'env', envVar: 'CODEGOV_RFC_REF' });
|
||||
expect(r.passed).toBe(true);
|
||||
delete process.env.CODEGOV_RFC_REF;
|
||||
});
|
||||
|
||||
it('fails when env var is missing', () => {
|
||||
delete process.env.CODEGOV_RFC_REF;
|
||||
const r = runRfcGate({ source: 'env', envVar: 'CODEGOV_RFC_REF' });
|
||||
expect(r.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('passes with custom pattern', () => {
|
||||
vi.spyOn(gitModule, 'getCommitMessage').mockReturnValue('Fix [JIRA-99]');
|
||||
const r = runRfcGate({ source: 'commit-message', pattern: 'JIRA-[0-9]+' });
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checklist gate', () => {
|
||||
beforeEach(() => { clean(); mkdirSync(TMP, { recursive: true }); });
|
||||
afterEach(() => { clean(); });
|
||||
|
||||
it('passes when all items checked', () => {
|
||||
writeFileSync(resolve(TMP, 'REQ.md'), '- [x] Feature A\n- [x] Feature B\n');
|
||||
const r = runChecklistGate({ filePath: resolve(TMP, 'REQ.md') });
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when an item is unchecked', () => {
|
||||
writeFileSync(resolve(TMP, 'REQ.md'), '- [x] Feature A\n- [ ] Feature B\n');
|
||||
const r = runChecklistGate({ filePath: resolve(TMP, 'REQ.md') });
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.details?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('fails when file is missing', () => {
|
||||
const r = runChecklistGate({ filePath: resolve(TMP, 'MISSING.md') });
|
||||
expect(r.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('fails when no checked items exist', () => {
|
||||
writeFileSync(resolve(TMP, 'REQ.md'), '- [ ] Feature A\n');
|
||||
const r = runChecklistGate({ filePath: resolve(TMP, 'REQ.md') });
|
||||
expect(r.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quality gate', () => {
|
||||
it('passes when no commands configured', () => {
|
||||
const r = runQualityGate({ commands: [] });
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('passes when all commands succeed', () => {
|
||||
const r = runQualityGate({ commands: ['echo ok'] });
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when a command fails', () => {
|
||||
const r = runQualityGate({ commands: ['exit 1'] });
|
||||
expect(r.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('runs multiple commands and reports failures', () => {
|
||||
const r = runQualityGate({ commands: ['echo ok', 'exit 1', 'exit 0'] });
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.details?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { execSync } from 'child_process';
|
||||
import { getChangedFiles, getCommitMessage, getPrDescription } from '../src/git';
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('git', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('returns changed files from git diff', () => {
|
||||
vi.mocked(execSync).mockReturnValue('src/a.ts\nsrc/b.ts\n');
|
||||
const files = getChangedFiles();
|
||||
expect(files).toEqual(['src/a.ts', 'src/b.ts']);
|
||||
});
|
||||
|
||||
it('returns empty array when no changes', () => {
|
||||
vi.mocked(execSync).mockReturnValue('');
|
||||
const files = getChangedFiles();
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses fromRef and toRef when provided', () => {
|
||||
vi.mocked(execSync).mockReturnValue('file.ts');
|
||||
getChangedFiles('main', 'HEAD');
|
||||
expect(vi.mocked(execSync)).toHaveBeenCalledWith(
|
||||
'git diff --name-only --diff-filter=ACM main...HEAD',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid git ref', () => {
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
const err = new Error('fatal: bad revision');
|
||||
throw err;
|
||||
});
|
||||
expect(() => getChangedFiles('bad-ref')).toThrow('Invalid git ref');
|
||||
});
|
||||
|
||||
it('throws when not a git repo', () => {
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
const err = new Error('fatal: not a git repository');
|
||||
throw err;
|
||||
});
|
||||
expect(() => getChangedFiles()).toThrow('Not a git repository');
|
||||
});
|
||||
|
||||
it('returns commit message', () => {
|
||||
vi.mocked(execSync).mockReturnValue('feat: add thing\n');
|
||||
expect(getCommitMessage()).toBe('feat: add thing');
|
||||
});
|
||||
|
||||
it('returns empty string when commit message fails', () => {
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
expect(getCommitMessage()).toBe('');
|
||||
});
|
||||
|
||||
it('reads PR description from env', () => {
|
||||
process.env.GITHUB_EVENT_PATH = 'pr-desc';
|
||||
expect(getPrDescription()).toBe('pr-desc');
|
||||
delete process.env.GITHUB_EVENT_PATH;
|
||||
});
|
||||
|
||||
it('returns empty string when no PR env', () => {
|
||||
delete process.env.GITHUB_EVENT_PATH;
|
||||
delete process.env.CI_MERGE_REQUEST_DESCRIPTION;
|
||||
delete process.env.BITBUCKET_PR_DESCRIPTION;
|
||||
expect(getPrDescription()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { runCheck } from '../src/index';
|
||||
|
||||
const TMP = resolve(__dirname, 'tmp-integration');
|
||||
|
||||
function clean() {
|
||||
try { rmSync(TMP, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
vi.mock('../src/git', () => ({
|
||||
getChangedFiles: vi.fn(),
|
||||
getCommitMessage: vi.fn(),
|
||||
getPrDescription: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as gitModule from '../src/git';
|
||||
|
||||
describe('integration', () => {
|
||||
beforeEach(() => {
|
||||
clean();
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clean();
|
||||
});
|
||||
|
||||
it('passes with no config and no changed files', async () => {
|
||||
vi.mocked(gitModule.getChangedFiles).mockReturnValue([]);
|
||||
vi.mocked(gitModule.getCommitMessage).mockReturnValue('feat: #123');
|
||||
writeFileSync(resolve(TMP, 'REQUIREMENTS.md'), '- [x] Done\n');
|
||||
const result = await runCheck({ cwd: TMP });
|
||||
expect(result.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when disclosure is missing', async () => {
|
||||
vi.mocked(gitModule.getChangedFiles).mockReturnValue([resolve(TMP, 'bad.ts')]);
|
||||
vi.mocked(gitModule.getCommitMessage).mockReturnValue('feat: #123');
|
||||
writeFileSync(resolve(TMP, 'bad.ts'), 'no marker');
|
||||
writeFileSync(resolve(TMP, 'REQUIREMENTS.md'), '- [x] Done\n');
|
||||
const result = await runCheck({ cwd: TMP });
|
||||
expect(result.passed).toBe(false);
|
||||
const disclosure = result.gateResults.find(g => g.name === 'disclosure');
|
||||
expect(disclosure?.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('fails when rfc is missing', async () => {
|
||||
vi.mocked(gitModule.getChangedFiles).mockReturnValue([]);
|
||||
vi.mocked(gitModule.getCommitMessage).mockReturnValue('just a commit');
|
||||
writeFileSync(resolve(TMP, 'REQUIREMENTS.md'), '- [x] Done\n');
|
||||
const result = await runCheck({ cwd: TMP });
|
||||
expect(result.passed).toBe(false);
|
||||
const rfc = result.gateResults.find(g => g.name === 'rfc');
|
||||
expect(rfc?.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('fails when checklist is incomplete', async () => {
|
||||
vi.mocked(gitModule.getChangedFiles).mockReturnValue([]);
|
||||
vi.mocked(gitModule.getCommitMessage).mockReturnValue('feat: #123');
|
||||
writeFileSync(resolve(TMP, 'REQUIREMENTS.md'), '- [ ] Not done\n');
|
||||
const result = await runCheck({ cwd: TMP });
|
||||
expect(result.passed).toBe(false);
|
||||
const checklist = result.gateResults.find(g => g.name === 'checklist');
|
||||
expect(checklist?.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('skips quality gate when not required', async () => {
|
||||
vi.mocked(gitModule.getChangedFiles).mockReturnValue([]);
|
||||
vi.mocked(gitModule.getCommitMessage).mockReturnValue('feat: #123');
|
||||
writeFileSync(resolve(TMP, 'REQUIREMENTS.md'), '- [x] Done\n');
|
||||
const result = await runCheck({ cwd: TMP });
|
||||
const quality = result.gateResults.find(g => g.name === 'quality');
|
||||
expect(quality).toBeUndefined();
|
||||
expect(result.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('honors disabled gates via config', async () => {
|
||||
writeFileSync(resolve(TMP, '.codegov.json'), JSON.stringify({
|
||||
enabled: true,
|
||||
gates: {
|
||||
disclosure: { required: false },
|
||||
rfc: { required: false },
|
||||
checklist: { required: false },
|
||||
},
|
||||
}));
|
||||
vi.mocked(gitModule.getChangedFiles).mockReturnValue([]);
|
||||
const result = await runCheck({ cwd: TMP });
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.gateResults.length).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user