-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy pathsuppression.ts
More file actions
145 lines (134 loc) · 5.93 KB
/
Copy pathsuppression.ts
File metadata and controls
145 lines (134 loc) · 5.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import ts from "@typescript/typescript6";
import { CHECKS } from "./checks/index.js";
const KNOWN_CHECK_IDS = new Set(CHECKS.map((c) => c.id));
/**
* The directive, and the reason that must follow it. The reason runs to the end of the line: `.` does
* not match a newline, so a suppression cannot pick up a reason from the next line. The old
* `obs-map-disable-next-line` spelling and why it was renamed rather than scoped: README,
* "Suppression".
*/
const PATTERN = /obs-map-disable\s+([a-z-]+)\s+--\s+(.+)/;
/**
* Every leaf token in the parsed source, through `.getChildren()` rather than `ts.forEachChild`, which
* silently skips a bare punctuation or keyword token. A comment can sit directly before one of those
* with nothing else following it, on the last line inside a block.
*/
function leafTokens(node: ts.Node): ts.Node[] {
const children = node.getChildren();
return children.length === 0 ? [node] : children.flatMap(leafTokens);
}
/**
* Node kinds whose text the parser has already claimed as content, so nothing inside their span can be
* trivia however it is spelled. `jsx text is content, not a comment` is the four cases that fail
* without `ts.isJsxText` here, and the positive control beside them, `still reads a directive from a
* comment in a JSX expression container`, is what stops the filter being widened until it eats real
* comments. The template and string kinds are covered by
* `does not suppress from a directive inside a template literal` and
* `ignores the directive inside a string literal`.
*
* The mutation corpus cannot cover any of this, because a suppression can only lower a score. See
* INTERNALS.md, "Reading the directive out of the source".
*/
function isClaimedContent(node: ts.Node): boolean {
return (
ts.isJsxText(node) ||
ts.isStringLiteral(node) ||
ts.isNoSubstitutionTemplateLiteral(node) ||
ts.isTemplateHead(node) ||
ts.isTemplateMiddle(node) ||
ts.isTemplateTail(node) ||
ts.isRegularExpressionLiteral(node)
);
}
/**
* Every comment range in the source, read off a real parsed `ts.SourceFile` rather than a standalone
* `ts.createScanner`, and then filtered against the spans above. Both halves are needed, and the
* filter is on the range's start offset rather than on the gap between a token's full start and its
* start: INTERNALS.md, "Reading the directive out of the source". Both lexers are called at every
* token
* boundary, because which one returns a given comment depends on whether it shares a line with the
* token before it.
*/
function commentRanges(source: string, sf: ts.SourceFile): ts.CommentRange[] {
const claimed: ts.TextRange[] = [];
const collectClaimed = (node: ts.Node) => {
if (isClaimedContent(node)) claimed.push({ pos: node.getStart(sf), end: node.end });
ts.forEachChild(node, collectClaimed);
};
collectClaimed(sf);
const inClaimedSpan = (pos: number) => claimed.some((s) => pos >= s.pos && pos < s.end);
const seen = new Set<number>();
const ranges: ts.CommentRange[] = [];
const add = (found: ts.CommentRange[] | undefined) => {
for (const range of found ?? []) {
if (seen.has(range.pos)) continue;
seen.add(range.pos);
if (inClaimedSpan(range.pos)) continue;
ranges.push(range);
}
};
for (const token of leafTokens(sf)) {
add(ts.getLeadingCommentRanges(source, token.getFullStart()));
add(ts.getTrailingCommentRanges(source, token.getEnd()));
}
return ranges;
}
/** One physical line of comment content per range, the `//`, `/*`, `*/` and a jsdoc `*` prefix
* stripped, so a multi-line block comment still matches the directive one line at a time. */
function commentLines(source: string, sf: ts.SourceFile): string[] {
const lines: string[] = [];
for (const range of commentRanges(source, sf)) {
const text = source.slice(range.pos, range.end);
if (range.kind === ts.SyntaxKind.SingleLineCommentTrivia) {
lines.push(text.slice(2));
continue;
}
const body = text.slice(2, text.length - 2); // drop the leading /* and the closing */
for (const rawLine of body.split("\n")) {
const trimmed = rawLine.trimStart();
lines.push(trimmed.startsWith("*") ? trimmed.slice(1) : rawLine);
}
}
return lines;
}
export type Suppressions = {
/** Check id to reason, for ids that name a check in `CHECKS`. */
byId: Map<string, string>;
/** Ids that parsed as a directive but name no check, deduplicated. A typo (`eror-classification`)
* used to land in the map, match nothing and appear nowhere, so the author read the finding as
* acknowledged while the tool kept reporting it. */
unknown: string[];
};
/**
* Every suppression directive in the source, split by whether its id names a real check. A directive
* without a reason, or outside a comment, is ignored either way.
*
* `fileName` picks the parser's script kind, because JSX is only distinguished from a generic type
* argument list (`<T>(x) => x`) when the file is really a `.tsx`.
*/
export function parseSuppressions(source: string, fileName = "check.ts"): Suppressions {
const scriptKind = fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sf = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
scriptKind
);
const byId = new Map<string, string>();
const unknown = new Set<string>();
for (const line of commentLines(source, sf)) {
const match = PATTERN.exec(line);
if (!match) continue;
const [, id, reason] = match;
const trimmedReason = reason?.trim();
if (!id || !trimmedReason || trimmedReason.length === 0) continue;
if (KNOWN_CHECK_IDS.has(id)) byId.set(id, trimmedReason);
else unknown.add(id);
}
return { byId, unknown: [...unknown] };
}
/** The known half of `parseSuppressions`, for callers that only apply suppressions. */
export function suppressedChecks(source: string, fileName = "check.ts"): Map<string, string> {
return parseSuppressions(source, fileName).byId;
}