Skip to content

Commit ffde923

Browse files
Ortaorta
Orta
andauthored
Added @ts-expect-error to @ts-ignore directives (#36014)
* Added @ts-expect-error to @ts-ignore directives Similar to `// @ts-ignore`, but will itself cause a new error diagnostic if it does not cause an existing diagnostic to be ignored. Technical summary: 1. The scanner will now keep track of `CommentDirective`s it comes across: both `@ts-expect-error` and `@ts-ignore` 2. During type checking, the program will turn those directives into a map keying them by line number 3. For each diagnostic, if it's preceded by a directive, that directive is marked as "used" 4. All `@ts-expect-error` directives not marked as used generate a new diagnostic error * Renamed to getDiagnosticsWithPrecedingDirectives per suggestion * Added JSDoc comment I thought I did already Co-authored-by: Orta <[email protected]>
1 parent 3c0c01c commit ffde923

17 files changed

+415
-41
lines changed

src/compiler/diagnosticMessages.json

+4
Original file line numberDiff line numberDiff line change
@@ -2245,6 +2245,10 @@
22452245
"category": "Error",
22462246
"code": 2577
22472247
},
2248+
"Unused '@ts-expect-error' directive.": {
2249+
"category": "Error",
2250+
"code": 2578
2251+
},
22482252
"Cannot find name '{0}'. Do you need to install type definitions for node? Try `npm i @types/node`.": {
22492253
"category": "Error",
22502254
"code": 2580

src/compiler/parser.ts

+2
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,7 @@ namespace ts {
915915

916916
function clearState() {
917917
// Clear out the text the scanner is pointing at, so it doesn't keep anything alive unnecessarily.
918+
scanner.clearCommentDirectives();
918919
scanner.setText("");
919920
scanner.setOnError(undefined);
920921

@@ -948,6 +949,7 @@ namespace ts {
948949

949950
setExternalModuleIndicator(sourceFile);
950951

952+
sourceFile.commentDirectives = scanner.getCommentDirectives();
951953
sourceFile.nodeCount = nodeCount;
952954
sourceFile.identifierCount = identifierCount;
953955
sourceFile.identifiers = identifiers;

src/compiler/program.ts

+61-41
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
namespace ts {
2-
const ignoreDiagnosticCommentRegEx = /(^\s*$)|(^\s*\/\/\/?\s*(@ts-ignore)?)/;
3-
42
export function findConfigFile(searchPath: string, fileExists: (fileName: string) => boolean, configName = "tsconfig.json"): string | undefined {
53
return forEachAncestorDirectory(searchPath, ancestor => {
64
const fileName = combinePaths(ancestor, configName);
@@ -1661,17 +1659,16 @@ namespace ts {
16611659
const fileProcessingDiagnosticsInFile = fileProcessingDiagnostics.getDiagnostics(sourceFile.fileName);
16621660
const programDiagnosticsInFile = programDiagnostics.getDiagnostics(sourceFile.fileName);
16631661

1664-
let diagnostics: Diagnostic[] | undefined;
1665-
for (const diags of [fileProcessingDiagnosticsInFile, programDiagnosticsInFile]) {
1666-
if (diags) {
1667-
for (const diag of diags) {
1668-
if (shouldReportDiagnostic(diag)) {
1669-
diagnostics = append(diagnostics, diag);
1670-
}
1671-
}
1672-
}
1662+
return getMergedProgramDiagnostics(sourceFile, fileProcessingDiagnosticsInFile, programDiagnosticsInFile);
1663+
}
1664+
1665+
function getMergedProgramDiagnostics(sourceFile: SourceFile, ...allDiagnostics: (readonly Diagnostic[] | undefined)[]) {
1666+
const flatDiagnostics = flatten(allDiagnostics);
1667+
if (!sourceFile.commentDirectives?.length) {
1668+
return flatDiagnostics;
16731669
}
1674-
return diagnostics || emptyArray;
1670+
1671+
return getDiagnosticsWithPrecedingDirectives(sourceFile, sourceFile.commentDirectives, flatDiagnostics).diagnostics;
16751672
}
16761673

16771674
function getDeclarationDiagnostics(sourceFile?: SourceFile, cancellationToken?: CancellationToken): readonly DiagnosticWithLocation[] {
@@ -1749,49 +1746,72 @@ namespace ts {
17491746
const bindDiagnostics: readonly Diagnostic[] = includeBindAndCheckDiagnostics ? sourceFile.bindDiagnostics : emptyArray;
17501747
const checkDiagnostics = includeBindAndCheckDiagnostics ? typeChecker.getDiagnostics(sourceFile, cancellationToken) : emptyArray;
17511748

1752-
let diagnostics: Diagnostic[] | undefined;
1753-
for (const diags of [bindDiagnostics, checkDiagnostics, isCheckJs ? sourceFile.jsDocDiagnostics : undefined]) {
1754-
if (diags) {
1755-
for (const diag of diags) {
1756-
if (shouldReportDiagnostic(diag)) {
1757-
diagnostics = append(diagnostics, diag);
1758-
}
1759-
}
1760-
}
1761-
}
1762-
return diagnostics || emptyArray;
1749+
return getMergedBindAndCheckDiagnostics(sourceFile, bindDiagnostics, checkDiagnostics, isCheckJs ? sourceFile.jsDocDiagnostics : undefined);
17631750
});
17641751
}
17651752

1753+
function getMergedBindAndCheckDiagnostics(sourceFile: SourceFile, ...allDiagnostics: (readonly Diagnostic[] | undefined)[]) {
1754+
const flatDiagnostics = flatten(allDiagnostics);
1755+
if (!sourceFile.commentDirectives?.length) {
1756+
return flatDiagnostics;
1757+
}
1758+
1759+
const { diagnostics, directives } = getDiagnosticsWithPrecedingDirectives(sourceFile, sourceFile.commentDirectives, flatDiagnostics);
1760+
1761+
for (const errorExpectation of directives.getUnusedExpectations()) {
1762+
diagnostics.push(createDiagnosticForRange(sourceFile, errorExpectation.range, Diagnostics.Unused_ts_expect_error_directive));
1763+
}
1764+
1765+
return diagnostics;
1766+
}
1767+
1768+
/**
1769+
* Creates a map of comment directives along with the diagnostics immediately preceded by one of them.
1770+
* Comments that match to any of those diagnostics are marked as used.
1771+
*/
1772+
function getDiagnosticsWithPrecedingDirectives(sourceFile: SourceFile, commentDirectives: CommentDirective[], flatDiagnostics: Diagnostic[]) {
1773+
// Diagnostics are only reported if there is no comment directive preceding them
1774+
// This will modify the directives map by marking "used" ones with a corresponding diagnostic
1775+
const directives = createCommentDirectivesMap(sourceFile, commentDirectives);
1776+
const diagnostics = flatDiagnostics.filter(diagnostic => markPrecedingCommentDirectiveLine(diagnostic, directives) === -1);
1777+
1778+
return { diagnostics, directives };
1779+
}
1780+
17661781
function getSuggestionDiagnostics(sourceFile: SourceFile, cancellationToken: CancellationToken): readonly DiagnosticWithLocation[] {
17671782
return runWithCancellationToken(() => {
17681783
return getDiagnosticsProducingTypeChecker().getSuggestionDiagnostics(sourceFile, cancellationToken);
17691784
});
17701785
}
17711786

17721787
/**
1773-
* Skip errors if previous line start with '// @ts-ignore' comment, not counting non-empty non-comment lines
1788+
* @returns The line index marked as preceding the diagnostic, or -1 if none was.
17741789
*/
1775-
function shouldReportDiagnostic(diagnostic: Diagnostic) {
1790+
function markPrecedingCommentDirectiveLine(diagnostic: Diagnostic, directives: CommentDirectivesMap) {
17761791
const { file, start } = diagnostic;
1777-
if (file) {
1778-
const lineStarts = getLineStarts(file);
1779-
let { line } = computeLineAndCharacterOfPosition(lineStarts, start!); // TODO: GH#18217
1780-
while (line > 0) {
1781-
const previousLineText = file.text.slice(lineStarts[line - 1], lineStarts[line]);
1782-
const result = ignoreDiagnosticCommentRegEx.exec(previousLineText);
1783-
if (!result) {
1784-
// non-empty line
1785-
return true;
1786-
}
1787-
if (result[3]) {
1788-
// @ts-ignore
1789-
return false;
1790-
}
1791-
line--;
1792+
if (!file) {
1793+
return -1;
1794+
}
1795+
1796+
// Start out with the line just before the text
1797+
const lineStarts = getLineStarts(file);
1798+
let line = computeLineAndCharacterOfPosition(lineStarts, start!).line - 1; // TODO: GH#18217
1799+
while (line >= 0) {
1800+
// As soon as that line is known to have a comment directive, use that
1801+
if (directives.markUsed(line)) {
1802+
return line;
17921803
}
1804+
1805+
// Stop searching if the line is not empty and not a comment
1806+
const lineText = file.text.slice(lineStarts[line - 1], lineStarts[line]).trim();
1807+
if (lineText !== "" && !/^(\s*)\/\/(.*)$/.test(lineText)) {
1808+
return -1;
1809+
}
1810+
1811+
line--;
17931812
}
1794-
return true;
1813+
1814+
return -1;
17951815
}
17961816

17971817
function getJSSyntacticDiagnosticsForFile(sourceFile: SourceFile): DiagnosticWithLocation[] {

src/compiler/scanner.ts

+46
Large diffs are not rendered by default.

src/compiler/types.ts

+20
Original file line numberDiff line numberDiff line change
@@ -2990,6 +2990,8 @@ namespace ts {
29902990
// This field should never be used directly to obtain line map, use getLineMap function instead.
29912991
/* @internal */ lineMap: readonly number[];
29922992
/* @internal */ classifiableNames?: ReadonlyUnderscoreEscapedMap<true>;
2993+
// Comments containing @ts-* directives, in order.
2994+
/* @internal */ commentDirectives?: CommentDirective[];
29932995
// Stores a mapping 'external module reference text' -> 'resolved file name' | undefined
29942996
// It is used to resolve module names in the checker.
29952997
// Content of this field should never be used directly - use getResolvedModuleFileName/setResolvedModuleFileName functions instead
@@ -3009,6 +3011,18 @@ namespace ts {
30093011
/*@internal*/ exportedModulesFromDeclarationEmit?: ExportedModulesFromDeclarationEmit;
30103012
}
30113013

3014+
/* @internal */
3015+
export interface CommentDirective {
3016+
range: TextRange;
3017+
type: CommentDirectiveType,
3018+
}
3019+
3020+
/* @internal */
3021+
export const enum CommentDirectiveType {
3022+
ExpectError,
3023+
Ignore,
3024+
}
3025+
30123026
/*@internal*/
30133027
export type ExportedModulesFromDeclarationEmit = readonly Symbol[];
30143028

@@ -6667,6 +6681,12 @@ namespace ts {
66676681
forEach(action: <TKey extends keyof PragmaPseudoMap>(value: PragmaPseudoMap[TKey] | PragmaPseudoMap[TKey][], key: TKey) => void): void;
66686682
}
66696683

6684+
/* @internal */
6685+
export interface CommentDirectivesMap {
6686+
getUnusedExpectations(): CommentDirective[];
6687+
markUsed(matchedLine: number): boolean;
6688+
}
6689+
66706690
export interface UserPreferences {
66716691
readonly disableSuggestions?: boolean;
66726692
readonly quotePreference?: "auto" | "double" | "single";

src/compiler/utilities.ts

+39
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,34 @@ namespace ts {
470470
text.charCodeAt(start + 2) === CharacterCodes.exclamation;
471471
}
472472

473+
export function createCommentDirectivesMap(sourceFile: SourceFile, commentDirectives: CommentDirective[]): CommentDirectivesMap {
474+
const directivesByLine = createMapFromEntries(
475+
commentDirectives.map(commentDirective => ([
476+
`${getLineAndCharacterOfPosition(sourceFile, commentDirective.range.pos).line}`,
477+
commentDirective,
478+
]))
479+
);
480+
481+
const usedLines = createMap<boolean>();
482+
483+
return { getUnusedExpectations, markUsed };
484+
485+
function getUnusedExpectations() {
486+
return arrayFrom(directivesByLine.entries())
487+
.filter(([line, directive]) => directive.type === CommentDirectiveType.ExpectError && !usedLines.get(line))
488+
.map(([_, directive]) => directive);
489+
}
490+
491+
function markUsed(line: number) {
492+
if (!directivesByLine.has(`${line}`)) {
493+
return false;
494+
}
495+
496+
usedLines.set(`${line}`, true);
497+
return true;
498+
}
499+
}
500+
473501
export function getTokenPosOfNode(node: Node, sourceFile?: SourceFileLike, includeJsDoc?: boolean): number {
474502
// With nodes that have no width (i.e. 'Missing' nodes), we actually *don't*
475503
// want to skip trivia because this will launch us forward to the next token.
@@ -914,6 +942,17 @@ namespace ts {
914942
};
915943
}
916944

945+
export function createDiagnosticForRange(sourceFile: SourceFile, range: TextRange, message: DiagnosticMessage): DiagnosticWithLocation {
946+
return {
947+
file: sourceFile,
948+
start: range.pos,
949+
length: range.end - range.pos,
950+
code: message.code,
951+
category: message.category,
952+
messageText: message.message,
953+
};
954+
}
955+
917956
export function getSpanOfTokenAtPosition(sourceFile: SourceFile, pos: number): TextSpan {
918957
const scanner = createScanner(sourceFile.languageVersion, /*skipTrivia*/ true, sourceFile.languageVariant, sourceFile.text, /*onError:*/ undefined, pos);
919958
scanner.scan();

src/services/services.ts

+1
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,7 @@ namespace ts {
614614
private namedDeclarations: Map<Declaration[]> | undefined;
615615
public ambientModuleNames!: string[];
616616
public checkJsDirective: CheckJsDirective | undefined;
617+
public errorExpectations: TextRange[] | undefined;
617618
public possiblyContainDynamicImport?: boolean;
618619
public pragmas!: PragmaMap;
619620
public localJsxFactory: EntityName | undefined;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
tests/cases/conformance/directives/ts-expect-error.ts(4,1): error TS2578: Unused '@ts-expect-error' directive.
2+
tests/cases/conformance/directives/ts-expect-error.ts(10,1): error TS2578: Unused '@ts-expect-error' directive.
3+
tests/cases/conformance/directives/ts-expect-error.ts(13,5): error TS2322: Type '"nope"' is not assignable to type 'number'.
4+
5+
6+
==== tests/cases/conformance/directives/ts-expect-error.ts (3 errors) ====
7+
// @ts-expect-error additional commenting
8+
var invalidCommentedFancy: number = 'nope';
9+
10+
// @ts-expect-error additional commenting
11+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
12+
!!! error TS2578: Unused '@ts-expect-error' directive.
13+
var validCommentedFancy: string = 'nope';
14+
15+
// @ts-expect-error
16+
var invalidCommentedPlain: number = 'nope';
17+
18+
// @ts-expect-error
19+
~~~~~~~~~~~~~~~~~~~
20+
!!! error TS2578: Unused '@ts-expect-error' directive.
21+
var validCommentedPlain: string = 'nope';
22+
23+
var invalidPlain: number = 'nope';
24+
~~~~~~~~~~~~
25+
!!! error TS2322: Type '"nope"' is not assignable to type 'number'.
26+
27+
var validPlain: string = 'nope';
28+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//// [ts-expect-error.ts]
2+
// @ts-expect-error additional commenting
3+
var invalidCommentedFancy: number = 'nope';
4+
5+
// @ts-expect-error additional commenting
6+
var validCommentedFancy: string = 'nope';
7+
8+
// @ts-expect-error
9+
var invalidCommentedPlain: number = 'nope';
10+
11+
// @ts-expect-error
12+
var validCommentedPlain: string = 'nope';
13+
14+
var invalidPlain: number = 'nope';
15+
16+
var validPlain: string = 'nope';
17+
18+
19+
//// [ts-expect-error.js]
20+
// @ts-expect-error additional commenting
21+
var invalidCommentedFancy = 'nope';
22+
// @ts-expect-error additional commenting
23+
var validCommentedFancy = 'nope';
24+
// @ts-expect-error
25+
var invalidCommentedPlain = 'nope';
26+
// @ts-expect-error
27+
var validCommentedPlain = 'nope';
28+
var invalidPlain = 'nope';
29+
var validPlain = 'nope';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
=== tests/cases/conformance/directives/ts-expect-error.ts ===
2+
// @ts-expect-error additional commenting
3+
var invalidCommentedFancy: number = 'nope';
4+
>invalidCommentedFancy : Symbol(invalidCommentedFancy, Decl(ts-expect-error.ts, 1, 3))
5+
6+
// @ts-expect-error additional commenting
7+
var validCommentedFancy: string = 'nope';
8+
>validCommentedFancy : Symbol(validCommentedFancy, Decl(ts-expect-error.ts, 4, 3))
9+
10+
// @ts-expect-error
11+
var invalidCommentedPlain: number = 'nope';
12+
>invalidCommentedPlain : Symbol(invalidCommentedPlain, Decl(ts-expect-error.ts, 7, 3))
13+
14+
// @ts-expect-error
15+
var validCommentedPlain: string = 'nope';
16+
>validCommentedPlain : Symbol(validCommentedPlain, Decl(ts-expect-error.ts, 10, 3))
17+
18+
var invalidPlain: number = 'nope';
19+
>invalidPlain : Symbol(invalidPlain, Decl(ts-expect-error.ts, 12, 3))
20+
21+
var validPlain: string = 'nope';
22+
>validPlain : Symbol(validPlain, Decl(ts-expect-error.ts, 14, 3))
23+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
=== tests/cases/conformance/directives/ts-expect-error.ts ===
2+
// @ts-expect-error additional commenting
3+
var invalidCommentedFancy: number = 'nope';
4+
>invalidCommentedFancy : number
5+
>'nope' : "nope"
6+
7+
// @ts-expect-error additional commenting
8+
var validCommentedFancy: string = 'nope';
9+
>validCommentedFancy : string
10+
>'nope' : "nope"
11+
12+
// @ts-expect-error
13+
var invalidCommentedPlain: number = 'nope';
14+
>invalidCommentedPlain : number
15+
>'nope' : "nope"
16+
17+
// @ts-expect-error
18+
var validCommentedPlain: string = 'nope';
19+
>validCommentedPlain : string
20+
>'nope' : "nope"
21+
22+
var invalidPlain: number = 'nope';
23+
>invalidPlain : number
24+
>'nope' : "nope"
25+
26+
var validPlain: string = 'nope';
27+
>validPlain : string
28+
>'nope' : "nope"
29+

0 commit comments

Comments
 (0)