From b1f080fc55567ff684fdd7d55196e986df1e43a1 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 24 Aug 2026 15:58:49 +1000 Subject: [PATCH 1/3] test: cover class field annotations --- fixtures/any-usage/class-fields.ts | 8 ++++++++ src/analyzer.test.ts | 8 ++++++++ src/cli.test.ts | 7 +++++++ src/parser.test.ts | 31 ++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 fixtures/any-usage/class-fields.ts 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 }; From 9bbf25dcea3631f7306b826078a06bd63a800775 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 24 Aug 2026 15:59:38 +1000 Subject: [PATCH 2/3] feat: audit annotated class fields --- src/parser.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) 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 */ /* ------------------------------------------------------------------ */ From 0258f5b33b6e716e8632550d6643a32810608aef Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 24 Aug 2026 15:59:57 +1000 Subject: [PATCH 3/3] docs: describe class field coverage --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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