diff --git a/src/cli.test.ts b/src/cli.test.ts index beac1e9..6ee0cee 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import { execSync, spawnSync } from 'node:child_process'; -import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; describe('CLI integration', () => { const cli = 'npx tsx src/cli.ts'; @@ -36,6 +38,24 @@ describe('CLI integration', () => { }); }); + it('keeps JSON output parseable when saving a baseline', () => { + const directory = mkdtempSync(join(tmpdir(), 'typegap-baseline-')); + const baseline = join(directory, 'coverage.json'); + try { + const result = spawnSync('npx', [ + 'tsx', 'src/cli.ts', 'fixtures/any-usage', '--format', 'json', '--baseline', baseline, + ], { encoding: 'utf8' }); + + expect(result.status).toBe(0); + const report = JSON.parse(result.stdout) as { total: number; coverage: number }; + const saved = JSON.parse(readFileSync(baseline, 'utf8')) as { total: number; coverage: number }; + expect(saved).toMatchObject({ total: report.total, coverage: report.coverage }); + expect(result.stderr).toContain(`Baseline saved to ${baseline}`); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + it('shows detail mode', () => { const output = execSync(`${cli} fixtures/mixed --detail`, { encoding: 'utf-8' }); expect(output).toContain('Details'); diff --git a/src/cli.ts b/src/cli.ts index 92eba33..1948be8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -87,7 +87,9 @@ program if (opts.baseline) { saveBaseline(result, resolve(opts.baseline as string), process.cwd()); - console.log(`Baseline saved to ${opts.baseline}`); + const confirmation = `Baseline saved to ${opts.baseline}`; + if (format === 'json') console.error(confirmation); + else console.log(confirmation); } process.exit(exitCode); diff --git a/src/parser.test.ts b/src/parser.test.ts index f70a471..55e82dd 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -138,6 +138,16 @@ describe('classifyTypeAnnotation for TSInferType', () => { }); describe('classifyTypeAnnotation for nested weak types', () => { + it.each([ + ['keyof any', AnnotationStatus.any], + ['keyof unknown', AnnotationStatus.unknown], + ['readonly any[]', AnnotationStatus.any], + ['readonly unknown[]', AnnotationStatus.unknown], + ])('classifies a type-operator wrapped %s', (annotation, expected) => { + const nodes = parseSource(`const value: ${annotation} = null as never;`); + expect(nodes.find(node => node.kind === 'var')?.status).toBe(expected); + }); + it.each([ ['property', '{ value: any }', AnnotationStatus.any], ['unknown property', '{ value: unknown }', AnnotationStatus.unknown], diff --git a/src/parser.ts b/src/parser.ts index a0df840..9935ebd 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -426,13 +426,21 @@ export function classifyTypeAnnotation(typeNode: TSESTree.TypeNode): AnnotationS return classifyTypeAnnotation(rt.typeAnnotation); } + // TSTypeOperator + if (typeNode.type === 'TSTypeOperator') { + const operator = typeNode as TSESTree.TSTypeOperator; + return operator.typeAnnotation + ? classifyTypeAnnotation(operator.typeAnnotation) + : AnnotationStatus.explicit; + } + // TSNamedTupleMember if (typeNode.type === 'TSNamedTupleMember') { const ntm = typeNode as TSESTree.TSNamedTupleMember; return classifyTypeAnnotation(ntm.elementType); } - // TSTypeOperator, TSInferType, TSIndexedAccessType handled above + // TSInferType and TSIndexedAccessType are handled above // Everything else is explicit return AnnotationStatus.explicit; }