diff --git a/README.md b/README.md index 3f3bfa2..91c1a7c 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,11 @@ TypeGap — Type Coverage Report | `--min-coverage ` | Exit non-zero if coverage drops below `n`% | | `--pattern ` | Custom glob pattern for target files | +Baselines are versioned JSON documents. TypeGap currently accepts version `1`, including the +numeric project totals and an array of file entries with a non-empty `file` path plus numeric +`total`, `annotated`, and `coverage` fields. Malformed JSON, unsupported versions, and invalid +fields are rejected with a concise error; `--format json` returns that error as JSON. + ## CI Integration CI should also run from a source checkout until the package is published: diff --git a/src/cli.test.ts b/src/cli.test.ts index d40b812..028e0ed 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { execSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { execSync, spawnSync } from 'node:child_process'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; describe('CLI integration', () => { const cli = 'npx tsx src/cli.ts'; @@ -73,4 +73,24 @@ describe('CLI integration', () => { execSync(`${cli} fixtures/fully-typed --compare fixtures/no-baseline.json`, { encoding: 'utf-8' }); }).toThrow(); }); + + it.each(['text', 'json'])('reports invalid baselines without a stack trace in %s mode', (format) => { + const baseline = 'fixtures/invalid-baseline-test.json'; + writeFileSync(baseline, '{not json\n'); + try { + const result = spawnSync('npx', ['tsx', 'src/cli.ts', 'fixtures/fully-typed', '--compare', baseline, '--format', format], { + encoding: 'utf8', + }); + expect(result.status).toBe(1); + const output = `${result.stdout}${result.stderr}`; + expect(output).toContain('Invalid baseline: malformed JSON'); + expect(output).not.toContain('SyntaxError'); + expect(output).not.toContain('at loadBaseline'); + if (format === 'json') { + expect(JSON.parse(result.stdout)).toEqual({ error: 'Invalid baseline: malformed JSON' }); + } + } finally { + rmSync(baseline, { force: true }); + } + }); }); diff --git a/src/cli.ts b/src/cli.ts index fdfc9f4..114e110 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -63,14 +63,24 @@ program pattern: opts.pattern as string | undefined, }); - const { output, exitCode } = generateReport(result, { - format, - detail: (opts.detail as boolean) ?? false, - saveBaseline: opts.baseline as string | undefined, - compareBaseline: opts.compare ? resolve(opts.compare as string) : undefined, - minCoverage, - cwd: process.cwd(), - }); + let report; + try { + report = generateReport(result, { + format, + detail: (opts.detail as boolean) ?? false, + saveBaseline: opts.baseline as string | undefined, + compareBaseline: opts.compare ? resolve(opts.compare as string) : undefined, + minCoverage, + cwd: process.cwd(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to load baseline'; + if (format === 'json') console.log(JSON.stringify({ error: message })); + else console.error(`Error: ${message}`); + process.exit(1); + } + + const { output, exitCode } = report; console.log(output); diff --git a/src/reporter.test.ts b/src/reporter.test.ts index c54bd9b..98c826e 100644 --- a/src/reporter.test.ts +++ b/src/reporter.test.ts @@ -3,7 +3,7 @@ import { generateReport, saveBaseline, loadBaseline, compareWithBaseline, getBas import { buildProjectResult } from './analyzer.js'; import { AnnotationStatus } from './types.js'; import type { FileResult } from './types.js'; -import { rmSync, existsSync } from 'node:fs'; +import { rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; const BASELINE_FILE = 'fixtures/baseline-test.json'; @@ -164,6 +164,23 @@ describe('saveBaseline / loadBaseline', () => { expect(() => loadBaseline('nonexistent.json')).toThrow(); }); + it('rejects malformed and unsupported baselines with stable errors', () => { + writeFileSync(BASELINE_FILE, '{not json\n'); + expect(() => loadBaseline(BASELINE_FILE)).toThrow('Invalid baseline: malformed JSON'); + + writeFileSync(BASELINE_FILE, JSON.stringify({ version: 2 })); + expect(() => loadBaseline(BASELINE_FILE)).toThrow('Invalid baseline: unsupported version 2; expected version 1'); + }); + + it('rejects invalid version-1 baseline fields', () => { + saveBaseline(makeResult(), BASELINE_FILE); + const baseline = JSON.parse(readFileSync(BASELINE_FILE, 'utf8')); + baseline.files[0].coverage = '75'; + writeFileSync(BASELINE_FILE, JSON.stringify(baseline)); + + expect(() => loadBaseline(BASELINE_FILE)).toThrow('Invalid baseline: files[0].coverage must be a finite number'); + }); + it('includes timestamp in baseline', () => { const result = makeResult(); saveBaseline(result, BASELINE_FILE); diff --git a/src/reporter.ts b/src/reporter.ts index 0a05cdd..f838e16 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -75,7 +75,52 @@ export function loadBaseline(filePath: string): Baseline { if (!existsSync(filePath)) { throw new Error(`Baseline file not found: ${filePath}`); } - return JSON.parse(readFileSync(filePath, 'utf-8')) as Baseline; + let value: unknown; + try { + value = JSON.parse(readFileSync(filePath, 'utf-8')); + } catch { + throw new Error('Invalid baseline: malformed JSON'); + } + return validateBaseline(value); +} + +function validateBaseline(value: unknown): Baseline { + if (!isRecord(value)) throw new Error('Invalid baseline: expected a JSON object'); + if (value.version !== 1) { + const version = value.version === undefined ? 'missing' : JSON.stringify(value.version); + throw new Error(`Invalid baseline: unsupported version ${version}; expected version 1`); + } + + for (const field of ['total', 'annotated', 'coverage', 'anyCount', 'unknownCount', 'implicitCount'] as const) { + requireFiniteNumber(value, field, field); + } + if (typeof value.timestamp !== 'string' || !Number.isFinite(Date.parse(value.timestamp))) { + throw new Error('Invalid baseline: timestamp must be a valid date string'); + } + if (!Array.isArray(value.files)) throw new Error('Invalid baseline: files must be an array'); + + value.files.forEach((file, index) => { + const prefix = `files[${index}]`; + if (!isRecord(file)) throw new Error(`Invalid baseline: ${prefix} must be an object`); + if (typeof file.file !== 'string' || file.file.length === 0) { + throw new Error(`Invalid baseline: ${prefix}.file must be a non-empty string`); + } + for (const field of ['total', 'annotated', 'coverage'] as const) { + requireFiniteNumber(file, field, `${prefix}.${field}`); + } + }); + + return value as unknown as Baseline; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireFiniteNumber(value: Record, field: string, label: string): void { + if (typeof value[field] !== 'number' || !Number.isFinite(value[field])) { + throw new Error(`Invalid baseline: ${label} must be a finite number`); + } } /** Compare current result against saved baseline */