diff --git a/README.md b/README.md index 91c1a7c..aac1b09 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ console.log(result.coverage); | ⚠️ **implicit** | Missing type annotations — implicit `any` or missing return types | It also catches weak types nested in generics (`Array`, `Record`). Interface declarations and direct object type aliases report their +any>`). Class fields with explicit annotations report one property finding, +including public, private, protected, static, readonly, declare, and optional +fields. Interface declarations and direct object type aliases report their properties, method parameters and returns, and index signatures individually. Function type parameters and returns nested in another annotation contribute to that enclosing finding. When one annotation contains both, `any` takes @@ -148,7 +150,7 @@ CI should also run from a source checkout until the package is published: 1. Finds all `.ts` / `.tsx` files using glob 2. Parses each file with `@typescript-eslint/typescript-estree` -3. Walks the AST for functions, arrows, parameters, variables +3. Walks the AST for functions, arrows, parameters, variables, and class fields 4. Classifies each annotatable node: explicit, `any`, `unknown`, or implicit 5. Calculates coverage as `(total - implicit) / total * 100` @@ -157,9 +159,12 @@ CI should also run from a source checkout until the package is published: - Function / arrow return types - Function / arrow parameters (including destructured and rest params) - Variable declarations with explicit type annotations +- Class fields with explicit type annotations (including private and modified fields) - Catch clause parameters -Constructor methods are excluded (they cannot have return types). Inferred-only variables (e.g., `const x = 5`) are not flagged — only explicit annotation sites are audited. +Constructor methods are excluded (they cannot have return types). Inferred-only +variables and class fields (for example, `const x = 5` or `value = 5`) are not +flagged — only explicit annotation sites are audited. ## Contributing diff --git a/fixtures/any-usage/class-fields.ts b/fixtures/any-usage/class-fields.ts new file mode 100644 index 0000000..2fc9170 --- /dev/null +++ b/fixtures/any-usage/class-fields.ts @@ -0,0 +1,8 @@ +export class Service { + payload: any; + readonly response?: Promise; + + run(input: string): any { + return input; + } +} diff --git a/src/analyzer.test.ts b/src/analyzer.test.ts index 5f4b324..87014be 100644 --- a/src/analyzer.test.ts +++ b/src/analyzer.test.ts @@ -121,6 +121,14 @@ describe('analyzeDirectory', () => { const result = await analyzeDirectory('fixtures/any-usage'); expect(result.files.length).toBeGreaterThan(0); expect(result.anyCount).toBeGreaterThan(0); + expect(result.files.find(file => file.file.endsWith('class-fields.ts'))).toMatchObject({ + total: 4, + annotated: 4, + anyCount: 2, + unknownCount: 1, + implicitCount: 0, + coverage: 100, + }); }); it('analyzes missing-types fixture with implicit issues', async () => { diff --git a/src/cli.test.ts b/src/cli.test.ts index 028e0ed..5387cb1 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -27,6 +27,13 @@ describe('CLI integration', () => { const json = JSON.parse(output); expect(json.coverage).toBe(100); // any counts as annotated (has a type) expect(json.anyCount).toBeGreaterThan(0); + expect(json.files.find((file: { file: string }) => file.file.endsWith('class-fields.ts'))).toMatchObject({ + total: 4, + annotated: 4, + anyCount: 2, + unknownCount: 1, + coverage: 100, + }); }); it('shows detail mode', () => { diff --git a/src/parser.test.ts b/src/parser.test.ts index 8073b10..f70a471 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -224,6 +224,37 @@ describe('object member annotations', () => { }); }); +describe('class field annotations', () => { + it('reports supported class field forms without double-counting method bodies', () => { + const nodes = parseSource(`class Service { + payload: any; + public response?: Promise; + private cache: Map = new Map(); + protected readonly label: string = 'service'; + static count: number = 0; + declare config: { value: unknown }; + #token: string = 'token'; + run(input: unknown): any { return input; } +}`); + + expect(nodes.map(({ kind, name, status }) => ({ kind, name, status }))).toEqual([ + { kind: 'property', name: 'payload', status: AnnotationStatus.any }, + { kind: 'property', name: 'response', status: AnnotationStatus.unknown }, + { kind: 'property', name: 'cache', status: AnnotationStatus.any }, + { kind: 'property', name: 'label', status: AnnotationStatus.explicit }, + { kind: 'property', name: 'count', status: AnnotationStatus.explicit }, + { kind: 'property', name: 'config', status: AnnotationStatus.unknown }, + { kind: 'property', name: '#token', status: AnnotationStatus.explicit }, + { kind: 'return', name: '(anonymous)() return', status: AnnotationStatus.any }, + { kind: 'param', name: 'input', status: AnnotationStatus.unknown }, + ]); + }); + + it('ignores inferred-only class fields consistently with variables', () => { + expect(parseSource('class Service { payload = 1; optional?; }')).toEqual([]); + }); +}); + describe('extractTypeName for edge cases', () => { const opts = { loc: true, range: true, jsx: false, tokens: false, comment: false }; diff --git a/src/parser.ts b/src/parser.ts index 06c700a..a0df840 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -66,6 +66,10 @@ function visit(node: TSESTree.Node, file: string, nodes: NodeInfo[], parent: TSE handleCatchClause(node as TSESTree.CatchClause, file, nodes); break; + case 'PropertyDefinition': + handleClassField(node as TSESTree.PropertyDefinition, file, nodes); + break; + case 'TSInterfaceDeclaration': handleObjectMembers((node as TSESTree.TSInterfaceDeclaration).body.body, file, nodes); break; @@ -195,6 +199,22 @@ function handleVariableDeclaration( } } +function handleClassField( + node: TSESTree.PropertyDefinition, + file: string, + nodes: NodeInfo[], +): void { + if (!node.typeAnnotation) return; + + nodes.push(makeNode( + file, + node.loc?.start.line ?? 0, + 'property', + classFieldName(node.key), + classifyTypeAnnotation(node.typeAnnotation.typeAnnotation), + )); +} + function handleObjectMembers( members: TSESTree.TypeElement[], file: string, @@ -232,6 +252,11 @@ function memberName(key: TSESTree.PropertyName): string { return '[computed]'; } +function classFieldName(key: TSESTree.PropertyDefinition['key']): string { + if (key.type === 'PrivateIdentifier') return `#${key.name}`; + return memberName(key); +} + /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */