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
146 changes: 146 additions & 0 deletions packages/cli/src/__tests__/why.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 51 additions & 0 deletions packages/cli/src/commands/why.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,36 @@ 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;
hasBaseline: boolean;
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<number> {
const snapshot = getSnapshot(options.configPath);

Expand All @@ -23,6 +43,34 @@ export async function quickstartCommand(options: CLIOptions): Promise<number> {
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.');
Expand Down Expand Up @@ -89,6 +137,7 @@ export async function whyCommand(options: CLIOptions): Promise<number> {
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 {
Expand All @@ -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',
};
}
Expand Down Expand Up @@ -128,6 +178,7 @@ function getSnapshot(configPath: string): SnapshotResult {
commitsScanned: commits.length,
coveragePercent,
highRiskUnlinked,
activePatterns,
nextAction,
};
}
Expand Down
Loading