diff --git a/packages/cli/src/__tests__/why.test.ts b/packages/cli/src/__tests__/why.test.ts new file mode 100644 index 0000000..9666fd2 --- /dev/null +++ b/packages/cli/src/__tests__/why.test.ts @@ -0,0 +1,146 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CLIOptions } from '../index'; +import { quickstartCommand } from '../commands/why'; + +const baseOptions: CLIOptions = { + configPath: '.charter', + format: 'text', + ciMode: false, + yes: false, +}; + +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-why-test-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + process.chdir(originalCwd); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +describe('quickstartCommand — not-installed repo shows adoption pitch', () => { + it('prints adoption pitch and returns 0 when no .charter/config.json present', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((msg: string) => logs.push(msg ?? '')); + + const exit = await quickstartCommand({ ...baseOptions, configPath: path.join(tmp, '.charter') }); + + expect(exit).toBe(0); + const output = logs.join('\n'); + expect(output).toContain('Charter Quickstart'); + expect(output).toContain('Why teams use Charter'); + expect(output).not.toContain('governance snapshot'); + }); +}); + +describe('quickstartCommand — installed repo shows posture view', () => { + let tmp: string; + let logs: string[]; + + beforeEach(() => { + tmp = makeTempDir(); + process.chdir(tmp); + fs.mkdirSync(path.join(tmp, '.charter'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.charter', 'config.json'), + JSON.stringify({ project: 'test', git: { requireTrailers: true } }), + ); + logs = []; + vi.spyOn(console, 'log').mockImplementation((msg: string) => logs.push(msg ?? '')); + }); + + it('shows governance snapshot header instead of adoption pitch', async () => { + const exit = await quickstartCommand({ ...baseOptions, configPath: path.join(tmp, '.charter') }); + + expect(exit).toBe(0); + const output = logs.join('\n'); + expect(output).toContain('governance snapshot'); + expect(output).not.toContain('Why teams use Charter'); + expect(output).not.toContain('Charter Quickstart'); + }); + + it('shows active pattern count', async () => { + fs.mkdirSync(path.join(tmp, '.charter', 'patterns')); + fs.writeFileSync( + path.join(tmp, '.charter', 'patterns', 'test.json'), + JSON.stringify({ patterns: [ + { id: 'p1', name: 'Pattern One', status: 'ACTIVE', blessed_solution: 'do x', anti_patterns: 'avoid y' }, + { id: 'p2', name: 'Pattern Two', status: 'ACTIVE', blessed_solution: 'do z', anti_patterns: 'avoid w' }, + ] }), + ); + + await quickstartCommand({ ...baseOptions, configPath: path.join(tmp, '.charter') }); + + expect(logs.join('\n')).toContain('2 active'); + }); + + it('exits 0 in ci mode when coverage and patterns are both ok', async () => { + fs.mkdirSync(path.join(tmp, '.charter', 'patterns')); + const patterns = Array.from({ length: 3 }, (_, i) => ({ + id: `p${i}`, name: `P${i}`, status: 'ACTIVE', blessed_solution: 'x', anti_patterns: 'y', + })); + fs.writeFileSync( + path.join(tmp, '.charter', 'patterns', 'test.json'), + JSON.stringify({ patterns }), + ); + + const exit = await quickstartCommand({ + ...baseOptions, + configPath: path.join(tmp, '.charter'), + ciMode: true, + }); + // No git repo in tmp → coverage=0 → fail signal, but patterns ≥3 ok. + // Coverage 0% → fail → ci mode should return POLICY_VIOLATION (1). + expect(exit).toBe(1); + }); + + it('exits 1 in ci mode when patterns is 0 (fail signal)', async () => { + const exit = await quickstartCommand({ + ...baseOptions, + configPath: path.join(tmp, '.charter'), + ciMode: true, + }); + expect(exit).toBe(1); + }); +}); + +describe('quickstartCommand --format json', () => { + it('includes activePatterns in JSON output for installed repo', async () => { + const tmp = makeTempDir(); + process.chdir(tmp); + fs.mkdirSync(path.join(tmp, '.charter', 'patterns'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.charter', 'config.json'), + JSON.stringify({ project: 'test' }), + ); + fs.writeFileSync( + path.join(tmp, '.charter', 'patterns', 'p.json'), + JSON.stringify({ patterns: [{ id: 'x', name: 'X', status: 'ACTIVE', blessed_solution: 'a', anti_patterns: 'b' }] }), + ); + + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((msg: string) => logs.push(msg ?? '')); + + await quickstartCommand({ ...baseOptions, format: 'json', configPath: path.join(tmp, '.charter') }); + + const data = JSON.parse(logs[0]); + expect(data).toHaveProperty('activePatterns'); + expect(data.activePatterns).toBe(1); + expect(data).toHaveProperty('hasBaseline', true); + }); +}); diff --git a/packages/cli/src/commands/why.ts b/packages/cli/src/commands/why.ts index c198da5..5946103 100644 --- a/packages/cli/src/commands/why.ts +++ b/packages/cli/src/commands/why.ts @@ -5,6 +5,9 @@ import { EXIT_CODE } from '../index'; import { parseAllTrailers, assessCommitRisk } from '@stackbilt/git'; import type { GitCommit } from '@stackbilt/types'; import { runGit, isGitRepo, hasCommits, parseCommitMetadata, parseChangedFilesByCommit } from '../git-helpers'; +import { loadPatterns } from '../config'; + +type Signal = 'ok' | 'warn' | 'fail'; interface SnapshotResult { inGitRepo: boolean; @@ -12,9 +15,26 @@ interface SnapshotResult { commitsScanned: number; coveragePercent: number; highRiskUnlinked: number; + activePatterns: number; nextAction: string; } +function coverageSignal(pct: number): Signal { + if (pct >= 50) return 'ok'; + if (pct >= 10) return 'warn'; + return 'fail'; +} + +function patternSignal(count: number): Signal { + if (count >= 3) return 'ok'; + if (count >= 1) return 'warn'; + return 'fail'; +} + +function signalTag(s: Signal): string { + return s === 'ok' ? '' : s === 'warn' ? ' [warn]' : ' [fail]'; +} + export async function quickstartCommand(options: CLIOptions): Promise { const snapshot = getSnapshot(options.configPath); @@ -23,6 +43,34 @@ export async function quickstartCommand(options: CLIOptions): Promise { return EXIT_CODE.SUCCESS; } + if (snapshot.hasBaseline) { + return printPostureView(snapshot, options.ciMode); + } + + return printAdoptionPitch(snapshot); +} + +function printPostureView(snapshot: SnapshotResult, ci: boolean): number { + const covSig = coverageSignal(snapshot.coveragePercent); + const patSig = patternSignal(snapshot.activePatterns); + const hasFail = covSig === 'fail' || patSig === 'fail'; + + const date = new Date().toISOString().slice(0, 10); + console.log(''); + console.log(` charter — governance snapshot (${date})`); + console.log(` Coverage: ${snapshot.coveragePercent}% of last ${snapshot.commitsScanned} commits${signalTag(covSig)}`); + console.log(` Patterns: ${snapshot.activePatterns} active${signalTag(patSig)}`); + if (snapshot.highRiskUnlinked > 0) { + console.log(` Risk: ${snapshot.highRiskUnlinked} high-risk commit(s) without governance links [warn]`); + } + console.log(''); + console.log(" Run 'charter audit' for full report · 'charter why' for adoption info"); + console.log(''); + + return ci && hasFail ? EXIT_CODE.POLICY_VIOLATION : EXIT_CODE.SUCCESS; +} + +function printAdoptionPitch(snapshot: SnapshotResult): number { console.log(''); console.log(' Charter Quickstart'); console.log(' Turns governance from abstract policy into merge-time guardrails.'); @@ -89,6 +137,7 @@ export async function whyCommand(options: CLIOptions): Promise { function getSnapshot(configPath: string): SnapshotResult { const inGitRepo = isGitRepo(); const hasBaseline = fs.existsSync(path.join(configPath, 'config.json')); + const activePatterns = hasBaseline ? loadPatterns(configPath).filter((p) => p.status === 'ACTIVE').length : 0; if (!inGitRepo) { return { @@ -97,6 +146,7 @@ function getSnapshot(configPath: string): SnapshotResult { commitsScanned: 0, coveragePercent: 0, highRiskUnlinked: 0, + activePatterns, nextAction: 'Run this inside a git repository, then run: charter setup --ci github', }; } @@ -128,6 +178,7 @@ function getSnapshot(configPath: string): SnapshotResult { commitsScanned: commits.length, coveragePercent, highRiskUnlinked, + activePatterns, nextAction, }; }