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
130 changes: 130 additions & 0 deletions packages/ci/src/__tests__/ci.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
setOutput,
setSummary,
annotateDriftViolations,
annotateValidationStatus,
formatPRComment,
} from '../index';
import type { DriftViolation } from '@stackbilt/types';

describe('@stackbilt/ci export boundary', () => {
it('exports setOutput as a function', () => {
expect(typeof setOutput).toBe('function');
});

it('exports setSummary as a function', () => {
expect(typeof setSummary).toBe('function');
});

it('exports annotateDriftViolations as a function', () => {
expect(typeof annotateDriftViolations).toBe('function');
});

it('exports annotateValidationStatus as a function', () => {
expect(typeof annotateValidationStatus).toBe('function');
});

it('exports formatPRComment as a function', () => {
expect(typeof formatPRComment).toBe('function');
});
});

describe('annotateDriftViolations', () => {
let logs: string[];
beforeEach(() => {
logs = [];
vi.spyOn(console, 'log').mockImplementation((msg: string) => logs.push(msg));
});
afterEach(() => { vi.restoreAllMocks(); });

it('emits ::error for BLOCKER severity', () => {
const violation: DriftViolation = {
file: 'src/index.ts',
line: 10,
patternName: 'no-eval',
snippet: 'eval(code)',
antiPattern: 'eval usage',
severity: 'BLOCKER',
};
annotateDriftViolations([violation]);
expect(logs[0]).toContain('::error');
expect(logs[0]).toContain('src/index.ts');
});

it('emits ::warning for MINOR severity', () => {
const violation: DriftViolation = {
file: 'src/util.ts',
line: 5,
patternName: 'prefer-const',
snippet: 'let x = 1',
antiPattern: 'mutable binding where const is sufficient',
severity: 'MINOR',
};
annotateDriftViolations([violation]);
expect(logs[0]).toContain('::warning');
});

it('emits nothing for empty violations', () => {
annotateDriftViolations([]);
expect(logs).toHaveLength(0);
});
});

describe('annotateValidationStatus', () => {
let logs: string[];
beforeEach(() => {
logs = [];
vi.spyOn(console, 'log').mockImplementation((msg: string) => logs.push(msg));
});
afterEach(() => { vi.restoreAllMocks(); });

it('emits ::error for FAIL status', () => {
annotateValidationStatus('FAIL', 'trailer missing');
expect(logs[0]).toContain('::error');
expect(logs[0]).toContain('trailer missing');
});

it('emits ::warning for WARN status', () => {
annotateValidationStatus('WARN', 'optional trailer absent');
expect(logs[0]).toContain('::warning');
});

it('emits nothing for PASS status', () => {
annotateValidationStatus('PASS', 'all good');
expect(logs).toHaveLength(0);
});
});

describe('formatPRComment', () => {
it('returns a string containing the status', () => {
const result = formatPRComment({ status: 'PASS', summary: 'All checks passed' });
expect(typeof result).toBe('string');
expect(result).toContain('PASS');
expect(result).toContain('All checks passed');
});

it('includes violations table when violations are provided', () => {
const violations: DriftViolation[] = [
{ file: 'src/a.ts', line: 1, patternName: 'no-any', snippet: 'any', antiPattern: 'untyped any', severity: 'CRITICAL' },
];
const result = formatPRComment({ status: 'FAIL', summary: 'Issues found', violations });
expect(result).toContain('Violations');
expect(result).toContain('src/a.ts');
});

it('includes suggestions list when suggestions are provided', () => {
const result = formatPRComment({
status: 'WARN',
summary: 'Review needed',
suggestions: ['Add trailer', 'Run tests'],
});
expect(result).toContain('Add trailer');
expect(result).toContain('Run tests');
});

it('includes drift score when score is provided', () => {
const result = formatPRComment({ status: 'PASS', summary: 'ok', score: 0.87 });
expect(result).toContain('87%');
});
});
34 changes: 33 additions & 1 deletion packages/classify/src/__tests__/classify.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { heuristicClassify, determineRecommendation } from '../index';
import { heuristicClassify, determineRecommendation, formatChangeClassification } from '../index';
import type { ChangeClassification } from '@stackbilt/types';

describe('heuristicClassify', () => {
it('classifies "readme" as SURFACE', () => {
Expand Down Expand Up @@ -46,6 +47,37 @@ describe('heuristicClassify', () => {
});
});

describe('formatChangeClassification export boundary', () => {
it('is exported as a function', () => {
expect(typeof formatChangeClassification).toBe('function');
});

it('returns a markdown string with required sections', () => {
const classification: ChangeClassification = {
id: 'test-1',
subjectType: 'PR',
subjectReference: 'PR-42',
subjectSummary: 'Add new auth middleware',
changeClass: 'CROSS_CUTTING',
affectedSystems: ['auth-service', 'api-gateway'],
affectedCount: 2,
policyViolations: [],
governanceStatus: 'CLEAR',
temporalAnalysisId: null,
recommendation: 'ESCALATE',
mitigations: [],
rationale: 'Cross-cutting change requires architecture review.',
projectId: null,
createdAt: '2026-01-01T00:00:00Z',
};
const output = formatChangeClassification(classification);
expect(typeof output).toBe('string');
expect(output).toContain('CROSS_CUTTING');
expect(output).toContain('ESCALATE');
expect(output).toContain('auth-service');
});
});

describe('determineRecommendation', () => {
it('returns REJECT for VIOLATION status', () => {
expect(determineRecommendation('SURFACE', 'VIOLATION', false)).toBe('REJECT');
Expand Down
115 changes: 115 additions & 0 deletions packages/validate/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import {
validateCitations,
extractCitations,
enrichCitations,
classifyMessage,
} from '../index';
import type { CitationBundle } from '../index';

// ============================================================================
// Export boundary — verify every top-level export is callable
// ============================================================================

describe('@stackbilt/validate export boundary', () => {
it('exports validateCitations as a function', () => {
expect(typeof validateCitations).toBe('function');
});

it('exports extractCitations as a function', () => {
expect(typeof extractCitations).toBe('function');
});

it('exports enrichCitations as a function', () => {
expect(typeof enrichCitations).toBe('function');
});

it('exports classifyMessage as a function', () => {
expect(typeof classifyMessage).toBe('function');
});
});

// ============================================================================
// extractCitations
// ============================================================================

describe('extractCitations', () => {
it('extracts section citations', () => {
const citations = extractCitations('See [Section 3.1] for details.');
expect(citations).toContain('Section 3.1');
});

it('extracts ADR citations', () => {
const citations = extractCitations('Per [ADR-042] and [ADR-007].');
expect(citations).toContain('ADR-042');
expect(citations).toContain('ADR-007');
});

it('returns empty array when no citations present', () => {
const citations = extractCitations('No citations here.');
expect(Array.isArray(citations)).toBe(true);
expect(citations).toHaveLength(0);
});
});

// ============================================================================
// validateCitations
// ============================================================================

function makeBundle(knownIds: string[]): CitationBundle {
const citationMap = new Map<string, unknown>(knownIds.map((id) => [id, true]));
return {
citationMap,
sections: [],
adrs: [],
patterns: [],
};
}

describe('validateCitations', () => {
it('returns valid=true when all citations are known', () => {
const bundle = makeBundle(['Section 2.1', 'ADR-001']);
const result = validateCitations('See [Section 2.1] and [ADR-001].', bundle);
expect(result.valid).toBe(true);
expect(result.violations).toHaveLength(0);
});

it('returns valid=false with violation when citation is unknown', () => {
const bundle = makeBundle([]);
const result = validateCitations('See [Section 99.9].', bundle);
expect(result.valid).toBe(false);
expect(result.violations.length).toBeGreaterThan(0);
expect(result.violations[0].errorType).toBe('NOT_FOUND_IN_DATABASE');
});

it('reports totalCitations and validCount', () => {
const bundle = makeBundle(['ADR-001']);
const result = validateCitations('[ADR-001] and [ADR-999]', bundle);
expect(result.totalCitations).toBe(2);
expect(result.validCount).toBe(1);
});
});

// ============================================================================
// classifyMessage
// ============================================================================

describe('classifyMessage', () => {
it('returns a classification object with expected shape', () => {
const result = classifyMessage('Should we use TypeScript or JavaScript?');
expect(result).toHaveProperty('intent');
expect(result).toHaveProperty('confidence');
expect(result).toHaveProperty('dudePhases');
expect(Array.isArray(result.dudePhases)).toBe(true);
});

it('classifies decision-style messages with decision intent', () => {
const result = classifyMessage('Which approach should we choose: A or B?');
expect(result.intent).toBe('decision');
});

it('classifies ideation-style messages with ideation intent', () => {
const result = classifyMessage("I'm thinking we could try a new architecture here.");
expect(result.intent).toBe('ideation');
});
});
Loading