From f3585dd45b1bfdfc8025da773a63b3dc5470f335 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 04:04:09 -0500 Subject: [PATCH 1/2] test: add export boundary tests for @stackbilt/ci, classify, and validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #167. Adds 30 new tests across three packages that had no or partial export coverage: - @stackbilt/ci: first test file — verifies all 5 exports are functions, exercises annotateDriftViolations (BLOCKER/LOW/empty), annotateValidationStatus (FAIL/WARN/PASS), and formatPRComment (status, violations, suggestions, score) - @stackbilt/classify: adds formatChangeClassification boundary test (export existence + markdown output shape); keeps existing heuristicClassify / determineRecommendation coverage - @stackbilt/validate: new exports.test.ts — boundary check for all 4 top-level exports (validateCitations, extractCitations, enrichCitations, classifyMessage) plus behavioral tests for citation extraction, validation result shape, and message intent classification @stackbilt/surface already has comprehensive route/schema coverage in surface.test.ts; no changes needed. Co-Authored-By: Claude Sonnet 4.6 --- packages/ci/src/__tests__/ci.test.ts | 130 ++++++++++++++++++ .../classify/src/__tests__/classify.test.ts | 34 ++++- .../validate/src/__tests__/exports.test.ts | 115 ++++++++++++++++ 3 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/ci/src/__tests__/ci.test.ts create mode 100644 packages/validate/src/__tests__/exports.test.ts diff --git a/packages/ci/src/__tests__/ci.test.ts b/packages/ci/src/__tests__/ci.test.ts new file mode 100644 index 0000000..c06748e --- /dev/null +++ b/packages/ci/src/__tests__/ci.test.ts @@ -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)', + severity: 'BLOCKER', + category: 'style', + }; + annotateDriftViolations([violation]); + expect(logs[0]).toContain('::error'); + expect(logs[0]).toContain('src/index.ts'); + }); + + it('emits ::warning for LOW severity', () => { + const violation: DriftViolation = { + file: 'src/util.ts', + line: 5, + patternName: 'prefer-const', + snippet: 'let x = 1', + severity: 'LOW', + category: 'style', + }; + 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', severity: 'CRITICAL', category: 'types' }, + ]; + 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%'); + }); +}); diff --git a/packages/classify/src/__tests__/classify.test.ts b/packages/classify/src/__tests__/classify.test.ts index 32d1961..caa29ab 100644 --- a/packages/classify/src/__tests__/classify.test.ts +++ b/packages/classify/src/__tests__/classify.test.ts @@ -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', () => { @@ -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'); diff --git a/packages/validate/src/__tests__/exports.test.ts b/packages/validate/src/__tests__/exports.test.ts new file mode 100644 index 0000000..ff8453b --- /dev/null +++ b/packages/validate/src/__tests__/exports.test.ts @@ -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(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'); + }); +}); From 7639b6378d96a6c72677ed3c95a7c7f2a644a8b0 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 23 May 2026 04:27:16 -0500 Subject: [PATCH 2/2] fix(ci-test): correct DriftViolation fixture fields per Codex review antiPattern replaces category (not in type); LOW replaced with MINOR (valid severities: BLOCKER | CRITICAL | MAJOR | MINOR). Co-Authored-By: Claude Sonnet 4.6 --- packages/ci/src/__tests__/ci.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ci/src/__tests__/ci.test.ts b/packages/ci/src/__tests__/ci.test.ts index c06748e..62245c5 100644 --- a/packages/ci/src/__tests__/ci.test.ts +++ b/packages/ci/src/__tests__/ci.test.ts @@ -44,22 +44,22 @@ describe('annotateDriftViolations', () => { line: 10, patternName: 'no-eval', snippet: 'eval(code)', + antiPattern: 'eval usage', severity: 'BLOCKER', - category: 'style', }; annotateDriftViolations([violation]); expect(logs[0]).toContain('::error'); expect(logs[0]).toContain('src/index.ts'); }); - it('emits ::warning for LOW severity', () => { + it('emits ::warning for MINOR severity', () => { const violation: DriftViolation = { file: 'src/util.ts', line: 5, patternName: 'prefer-const', snippet: 'let x = 1', - severity: 'LOW', - category: 'style', + antiPattern: 'mutable binding where const is sufficient', + severity: 'MINOR', }; annotateDriftViolations([violation]); expect(logs[0]).toContain('::warning'); @@ -106,7 +106,7 @@ describe('formatPRComment', () => { it('includes violations table when violations are provided', () => { const violations: DriftViolation[] = [ - { file: 'src/a.ts', line: 1, patternName: 'no-any', snippet: 'any', severity: 'CRITICAL', category: 'types' }, + { 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');