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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ TypeGap — Type Coverage Report
| `--min-coverage <n>` | Exit non-zero if coverage drops below `n`% |
| `--pattern <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:
Expand Down
24 changes: 22 additions & 2 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
}
});
});
26 changes: 18 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
19 changes: 18 additions & 1 deletion src/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
47 changes: 46 additions & 1 deletion src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function requireFiniteNumber(value: Record<string, unknown>, 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 */
Expand Down
Loading