@avi2dg/checks 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +256 -97
- package/oxlintrc.json +2 -2
- package/package.json +15 -6
- package/scripts/backtest.ts +25 -36
- package/scripts/ci-wiring.ts +138 -62
- package/scripts/comment-gate.ts +23 -41
- package/scripts/comment-matchers.ts +309 -0
- package/scripts/comments.ts +15 -292
- package/scripts/commit-identity.ts +91 -87
- package/scripts/gates.ts +59 -0
- package/scripts/git.ts +41 -0
- package/scripts/lint-coverage.sh +11 -5
- package/scripts/lint.ts +179 -0
- package/scripts/main.ts +28 -0
- package/scripts/mutation-compare.ts +43 -57
- package/scripts/suppressions-ratchet.ts +64 -76
- package/scripts/test-layout.ts +67 -44
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
export type Comment = { line: number; text: string };
|
|
2
|
+
|
|
3
|
+
type Marker = { readonly token: string; readonly afterAWordBreak: boolean };
|
|
4
|
+
type Quote = { readonly token: string; readonly backslashEscapes: boolean; readonly opensMidWord: boolean };
|
|
5
|
+
|
|
6
|
+
export type Syntax = {
|
|
7
|
+
readonly line: readonly Marker[];
|
|
8
|
+
readonly block: readonly (readonly [open: string, close: string])[];
|
|
9
|
+
readonly quotes: readonly Quote[];
|
|
10
|
+
readonly regexLiterals: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const anywhere = (token: string): Marker => ({ token, afterAWordBreak: false });
|
|
14
|
+
const startingAWord = (token: string): Marker => ({ token, afterAWordBreak: true });
|
|
15
|
+
const escaping = (token: string): Quote => ({ token, backslashEscapes: true, opensMidWord: true });
|
|
16
|
+
const literal = (token: string): Quote => ({ token, backslashEscapes: false, opensMidWord: true });
|
|
17
|
+
const outsideAWord = (quote: Quote): Quote => ({ ...quote, opensMidWord: false });
|
|
18
|
+
|
|
19
|
+
const SLASHES: readonly (readonly [string, string])[] = [["/*", "*/"]];
|
|
20
|
+
const SLASH_SLASH = [startingAWord("//")];
|
|
21
|
+
const DASHES = [anywhere("--")];
|
|
22
|
+
const STRINGS = [escaping('"'), escaping("'")];
|
|
23
|
+
|
|
24
|
+
const JS: Syntax = {
|
|
25
|
+
line: SLASH_SLASH,
|
|
26
|
+
block: SLASHES,
|
|
27
|
+
quotes: [escaping('"'), outsideAWord(escaping("'")), escaping("`")],
|
|
28
|
+
regexLiterals: true,
|
|
29
|
+
};
|
|
30
|
+
const CURLY: Syntax = { ...JS, quotes: [...STRINGS, escaping("`")], regexLiterals: false };
|
|
31
|
+
const PHP: Syntax = { line: [...SLASH_SLASH, anywhere("#")], block: SLASHES, quotes: STRINGS, regexLiterals: false };
|
|
32
|
+
const HASH: Syntax = {
|
|
33
|
+
line: [startingAWord("#")],
|
|
34
|
+
block: [],
|
|
35
|
+
quotes: [escaping('"'), literal("'")],
|
|
36
|
+
regexLiterals: false,
|
|
37
|
+
};
|
|
38
|
+
const YAML: Syntax = { ...HASH, quotes: [escaping('"'), outsideAWord(literal("'"))] };
|
|
39
|
+
const SQL: Syntax = { line: DASHES, block: SLASHES, quotes: STRINGS, regexLiterals: false };
|
|
40
|
+
const LUA: Syntax = { line: DASHES, block: [["--[[", "]]"]], quotes: STRINGS, regexLiterals: false };
|
|
41
|
+
const HASKELL: Syntax = { line: DASHES, block: [["{-", "-}"]], quotes: [escaping('"')], regexLiterals: false };
|
|
42
|
+
const ML: Syntax = { line: [], block: [["(*", "*)"]], quotes: [escaping('"')], regexLiterals: false };
|
|
43
|
+
|
|
44
|
+
export const SYNTAXES: Readonly<Record<string, Syntax>> = {
|
|
45
|
+
ts: JS,
|
|
46
|
+
tsx: JS,
|
|
47
|
+
mts: JS,
|
|
48
|
+
cts: JS,
|
|
49
|
+
js: JS,
|
|
50
|
+
jsx: JS,
|
|
51
|
+
mjs: JS,
|
|
52
|
+
cjs: JS,
|
|
53
|
+
go: CURLY,
|
|
54
|
+
rs: CURLY,
|
|
55
|
+
java: CURLY,
|
|
56
|
+
kt: CURLY,
|
|
57
|
+
swift: CURLY,
|
|
58
|
+
scala: CURLY,
|
|
59
|
+
c: CURLY,
|
|
60
|
+
h: CURLY,
|
|
61
|
+
cc: CURLY,
|
|
62
|
+
cpp: CURLY,
|
|
63
|
+
hpp: CURLY,
|
|
64
|
+
cs: CURLY,
|
|
65
|
+
php: PHP,
|
|
66
|
+
py: HASH,
|
|
67
|
+
rb: HASH,
|
|
68
|
+
ex: HASH,
|
|
69
|
+
exs: HASH,
|
|
70
|
+
sh: HASH,
|
|
71
|
+
bash: HASH,
|
|
72
|
+
zsh: HASH,
|
|
73
|
+
nix: HASH,
|
|
74
|
+
sql: SQL,
|
|
75
|
+
lua: LUA,
|
|
76
|
+
hs: HASKELL,
|
|
77
|
+
yaml: YAML,
|
|
78
|
+
yml: YAML,
|
|
79
|
+
toml: HASH,
|
|
80
|
+
ml: ML,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const extensionOf = (path: string): string => path.slice(path.lastIndexOf(".") + 1);
|
|
84
|
+
|
|
85
|
+
export function syntaxOf(path: string): Syntax | undefined {
|
|
86
|
+
const extension = extensionOf(path);
|
|
87
|
+
return Object.hasOwn(SYNTAXES, extension) ? SYNTAXES[extension] : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function unreadable(path: string): string {
|
|
91
|
+
return `${path} is code the comment checks cannot read: add a comment syntax for .${extensionOf(path)} to scripts/comment-matchers.ts`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const WORD = /[\w$]/;
|
|
95
|
+
|
|
96
|
+
const KEYWORDS = new Set([
|
|
97
|
+
"return",
|
|
98
|
+
"typeof",
|
|
99
|
+
"instanceof",
|
|
100
|
+
"in",
|
|
101
|
+
"of",
|
|
102
|
+
"new",
|
|
103
|
+
"delete",
|
|
104
|
+
"void",
|
|
105
|
+
"throw",
|
|
106
|
+
"case",
|
|
107
|
+
"do",
|
|
108
|
+
"else",
|
|
109
|
+
"yield",
|
|
110
|
+
"await",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const AFTER_A_VALUE = /[)\]}"'`]/;
|
|
114
|
+
|
|
115
|
+
function opensRegex(previous: string): boolean {
|
|
116
|
+
if (previous === "") return true;
|
|
117
|
+
if (WORD.test(previous.charAt(0))) return KEYWORDS.has(previous);
|
|
118
|
+
return !AFTER_A_VALUE.test(previous);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const WORD_BREAK = /[\s;|&(]/;
|
|
122
|
+
|
|
123
|
+
function endOfQuoted(source: string, start: number, quote: Quote): number {
|
|
124
|
+
let i = start + quote.token.length;
|
|
125
|
+
while (i < source.length) {
|
|
126
|
+
if (quote.backslashEscapes && source[i] === "\\") i += 2;
|
|
127
|
+
else if (source.startsWith(quote.token, i)) return i + quote.token.length;
|
|
128
|
+
else i += 1;
|
|
129
|
+
}
|
|
130
|
+
return start + quote.token.length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function endOfRegex(source: string, start: number): number {
|
|
134
|
+
let i = start + 1;
|
|
135
|
+
let inClass = false;
|
|
136
|
+
while (i < source.length) {
|
|
137
|
+
const char = source.charAt(i);
|
|
138
|
+
if (char === "\\") i += 2;
|
|
139
|
+
else if (char === "\n") return i;
|
|
140
|
+
else {
|
|
141
|
+
if (char === "[") inClass = true;
|
|
142
|
+
else if (char === "]") inClass = false;
|
|
143
|
+
else if (char === "/" && !inClass) return i + 1;
|
|
144
|
+
i += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return source.length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function commentsIn(source: string, syntax: Syntax): Comment[] {
|
|
151
|
+
const found: Comment[] = [];
|
|
152
|
+
let i = 0;
|
|
153
|
+
let line = 1;
|
|
154
|
+
let previous = "";
|
|
155
|
+
|
|
156
|
+
const advance = (to: number): void => {
|
|
157
|
+
for (let k = i; k < to; k++) if (source[k] === "\n") line += 1;
|
|
158
|
+
i = to;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
while (i < source.length) {
|
|
162
|
+
const char = source.charAt(i);
|
|
163
|
+
|
|
164
|
+
const block = syntax.block.find(([open]) => source.startsWith(open, i));
|
|
165
|
+
if (block) {
|
|
166
|
+
const [open, close] = block;
|
|
167
|
+
const closed = source.indexOf(close, i + open.length);
|
|
168
|
+
const stop = closed < 0 ? source.length : closed + close.length;
|
|
169
|
+
found.push({ line, text: source.slice(i, stop) });
|
|
170
|
+
advance(stop);
|
|
171
|
+
previous = "";
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const marker = syntax.line.find(
|
|
176
|
+
({ token, afterAWordBreak }) =>
|
|
177
|
+
source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source.charAt(i - 1))),
|
|
178
|
+
);
|
|
179
|
+
if (marker) {
|
|
180
|
+
const newline = source.indexOf("\n", i);
|
|
181
|
+
const stop = newline < 0 ? source.length : newline;
|
|
182
|
+
found.push({ line, text: source.slice(i, stop) });
|
|
183
|
+
advance(stop);
|
|
184
|
+
previous = "";
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const quote = syntax.quotes.find(
|
|
189
|
+
({ token, opensMidWord }) =>
|
|
190
|
+
source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source.charAt(i - 1))),
|
|
191
|
+
);
|
|
192
|
+
if (quote) {
|
|
193
|
+
advance(endOfQuoted(source, i, quote));
|
|
194
|
+
previous = quote.token;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (syntax.regexLiterals && char === "/" && opensRegex(previous)) {
|
|
199
|
+
advance(endOfRegex(source, i));
|
|
200
|
+
previous = "/";
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (WORD.test(char)) {
|
|
205
|
+
let end = i;
|
|
206
|
+
while (end < source.length && WORD.test(source.charAt(end))) end += 1;
|
|
207
|
+
previous = source.slice(i, end);
|
|
208
|
+
advance(end);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!/\s/.test(char)) previous = char;
|
|
213
|
+
advance(i + 1);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return found;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const LICENCE = /copyright|spdx-license-identifier|all rights reserved|licen[cs]ed under|permission is hereby granted/i;
|
|
220
|
+
|
|
221
|
+
// A single sentence wraps to at most a few lines at a normal width, so a longer run at the
|
|
222
|
+
// file's own opening is the shape of a paragraph rather than of one fact.
|
|
223
|
+
const A_WRAPPED_SENTENCE_FITS_WITHIN = 3;
|
|
224
|
+
|
|
225
|
+
function firstLine(text: string): string {
|
|
226
|
+
const newline = text.indexOf("\n");
|
|
227
|
+
return newline < 0 ? text : text.slice(0, newline);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function beginsItsLine(comment: Comment, lines: readonly string[]): boolean {
|
|
231
|
+
const line = lines[comment.line - 1];
|
|
232
|
+
return line !== undefined && line.trimStart().startsWith(firstLine(comment.text));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function openingBlock(found: readonly Comment[], source: string): Comment[] {
|
|
236
|
+
const lines = source.split("\n");
|
|
237
|
+
const block: Comment[] = [];
|
|
238
|
+
let next = 1;
|
|
239
|
+
for (const comment of found) {
|
|
240
|
+
if (comment.line !== next || !beginsItsLine(comment, lines)) break;
|
|
241
|
+
block.push(comment);
|
|
242
|
+
next = comment.line + comment.text.split("\n").length;
|
|
243
|
+
}
|
|
244
|
+
return block;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
type Check = {
|
|
248
|
+
readonly find: RegExp;
|
|
249
|
+
readonly refusal: (match: string) => string;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const CHECKS: readonly Check[] = [
|
|
253
|
+
{
|
|
254
|
+
find: /@ts-expect-error|@ts-ignore|\bprettier-ignore\b|\beslint-disable[\w-]*|\bbiome-ignore\b/,
|
|
255
|
+
refusal: (match) =>
|
|
256
|
+
`carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
find: /\bADR[\s-]?\d+|\bdocs\/adr\b|\bRFC[\s-]?\d+|(?:^|[\s(["'`])#\d+\b|[\w.-]+\/[\w.-]+#\d+|\/(?:issues|pull)\/\d+/i,
|
|
260
|
+
refusal: (match) =>
|
|
261
|
+
`points at a record or a ticket (${JSON.stringify(match.trim())}). Drop the pointer: a record is reached by searching docs/adr, and the story of the change goes in the commit message`,
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
find: /^\/\*\*/,
|
|
265
|
+
refusal: () =>
|
|
266
|
+
"opens a doc block. Make the code say it, or put what the types cannot hold on a line comment in the fewest words that carry it",
|
|
267
|
+
},
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
function spans(from: number, lineCount: number, within: ReadonlySet<number> | undefined): boolean {
|
|
271
|
+
if (within === undefined) return true;
|
|
272
|
+
for (let line = from; line < from + lineCount; line += 1) if (within.has(line)) return true;
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function refusalsIn(path: string, source: string, syntax: Syntax, within?: ReadonlySet<number>): string[] {
|
|
277
|
+
const out: string[] = [];
|
|
278
|
+
const found = commentsIn(source, syntax);
|
|
279
|
+
|
|
280
|
+
const opening = openingBlock(found, source);
|
|
281
|
+
const openingText = opening.map((comment) => comment.text).join("\n");
|
|
282
|
+
const openingLines = opening.reduce((sum, comment) => sum + comment.text.split("\n").length, 0);
|
|
283
|
+
if (
|
|
284
|
+
opening.length > 0 &&
|
|
285
|
+
openingLines > A_WRAPPED_SENTENCE_FITS_WITHIN &&
|
|
286
|
+
!LICENCE.test(openingText) &&
|
|
287
|
+
spans(1, openingLines, within)
|
|
288
|
+
) {
|
|
289
|
+
out.push(
|
|
290
|
+
`${path}:1 opens with a ${openingLines}-line rationale block. A record this long belongs in docs/adr or the repo's decision log, and the code does not point at it`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const comment of found) {
|
|
295
|
+
if (LICENCE.test(comment.text)) continue;
|
|
296
|
+
if (!spans(comment.line, comment.text.split("\n").length, within)) continue;
|
|
297
|
+
for (const check of CHECKS) {
|
|
298
|
+
const [match] = check.find.exec(comment.text) ?? [];
|
|
299
|
+
if (match !== undefined) out.push(`${path}:${comment.line} ${check.refusal(match)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function refused(path: string, source: string, within?: ReadonlySet<number>): string[] {
|
|
306
|
+
const syntax = syntaxOf(path);
|
|
307
|
+
if (syntax === undefined) throw new Error(unreadable(path));
|
|
308
|
+
return refusalsIn(path, source, syntax, within);
|
|
309
|
+
}
|
package/scripts/comments.ts
CHANGED
|
@@ -1,298 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { commentsIn, refusalsIn, syntaxOf, unreadable, type Syntax } from "./comment-matchers.ts";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
type Quote = { readonly token: string; readonly backslashEscapes: boolean; readonly opensMidWord: boolean };
|
|
4
|
+
export { SYNTAXES, syntaxOf, type Comment } from "./comment-matchers.ts";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
readonly quotes: readonly Quote[];
|
|
10
|
-
readonly regexLiterals: boolean;
|
|
11
|
-
};
|
|
6
|
+
export class UnreadableCode extends Schema.TaggedError<UnreadableCode>()("UnreadableCode", {
|
|
7
|
+
message: Schema.String,
|
|
8
|
+
}) {}
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const literal = (token: string): Quote => ({ token, backslashEscapes: false, opensMidWord: true });
|
|
17
|
-
const outsideAWord = (quote: Quote): Quote => ({ ...quote, opensMidWord: false });
|
|
18
|
-
|
|
19
|
-
const SLASHES: readonly (readonly [string, string])[] = [["/*", "*/"]];
|
|
20
|
-
const SLASH_SLASH = [startingAWord("//")];
|
|
21
|
-
const DASHES = [anywhere("--")];
|
|
22
|
-
const STRINGS = [escaping('"'), escaping("'")];
|
|
23
|
-
|
|
24
|
-
const JS: Syntax = {
|
|
25
|
-
line: SLASH_SLASH,
|
|
26
|
-
block: SLASHES,
|
|
27
|
-
quotes: [escaping('"'), outsideAWord(escaping("'")), escaping("`")],
|
|
28
|
-
regexLiterals: true,
|
|
29
|
-
};
|
|
30
|
-
const CURLY: Syntax = { ...JS, quotes: [...STRINGS, escaping("`")], regexLiterals: false };
|
|
31
|
-
const PHP: Syntax = { line: [...SLASH_SLASH, anywhere("#")], block: SLASHES, quotes: STRINGS, regexLiterals: false };
|
|
32
|
-
const HASH: Syntax = {
|
|
33
|
-
line: [startingAWord("#")],
|
|
34
|
-
block: [],
|
|
35
|
-
quotes: [escaping('"'), literal("'")],
|
|
36
|
-
regexLiterals: false,
|
|
37
|
-
};
|
|
38
|
-
const YAML: Syntax = { ...HASH, quotes: [escaping('"'), outsideAWord(literal("'"))] };
|
|
39
|
-
const SQL: Syntax = { line: DASHES, block: SLASHES, quotes: STRINGS, regexLiterals: false };
|
|
40
|
-
const LUA: Syntax = { line: DASHES, block: [["--[[", "]]"]], quotes: STRINGS, regexLiterals: false };
|
|
41
|
-
const HASKELL: Syntax = { line: DASHES, block: [["{-", "-}"]], quotes: [escaping('"')], regexLiterals: false };
|
|
42
|
-
const ML: Syntax = { line: [], block: [["(*", "*)"]], quotes: [escaping('"')], regexLiterals: false };
|
|
43
|
-
|
|
44
|
-
export const SYNTAXES: Readonly<Record<string, Syntax>> = {
|
|
45
|
-
ts: JS,
|
|
46
|
-
tsx: JS,
|
|
47
|
-
mts: JS,
|
|
48
|
-
cts: JS,
|
|
49
|
-
js: JS,
|
|
50
|
-
jsx: JS,
|
|
51
|
-
mjs: JS,
|
|
52
|
-
cjs: JS,
|
|
53
|
-
go: CURLY,
|
|
54
|
-
rs: CURLY,
|
|
55
|
-
java: CURLY,
|
|
56
|
-
kt: CURLY,
|
|
57
|
-
swift: CURLY,
|
|
58
|
-
scala: CURLY,
|
|
59
|
-
c: CURLY,
|
|
60
|
-
h: CURLY,
|
|
61
|
-
cc: CURLY,
|
|
62
|
-
cpp: CURLY,
|
|
63
|
-
hpp: CURLY,
|
|
64
|
-
cs: CURLY,
|
|
65
|
-
php: PHP,
|
|
66
|
-
py: HASH,
|
|
67
|
-
rb: HASH,
|
|
68
|
-
ex: HASH,
|
|
69
|
-
exs: HASH,
|
|
70
|
-
sh: HASH,
|
|
71
|
-
bash: HASH,
|
|
72
|
-
zsh: HASH,
|
|
73
|
-
nix: HASH,
|
|
74
|
-
sql: SQL,
|
|
75
|
-
lua: LUA,
|
|
76
|
-
hs: HASKELL,
|
|
77
|
-
yaml: YAML,
|
|
78
|
-
yml: YAML,
|
|
79
|
-
toml: HASH,
|
|
80
|
-
ml: ML,
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
export function syntaxFor(path: string): Syntax {
|
|
84
|
-
const extension = path.slice(path.lastIndexOf(".") + 1);
|
|
85
|
-
const syntax = SYNTAXES[extension];
|
|
86
|
-
if (!syntax) {
|
|
87
|
-
throw new Error(
|
|
88
|
-
`${path} is code the comment checks cannot read: add a comment syntax for .${extension} to scripts/comments.ts`,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
return syntax;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const WORD = /[\w$]/;
|
|
95
|
-
|
|
96
|
-
const KEYWORDS = new Set([
|
|
97
|
-
"return",
|
|
98
|
-
"typeof",
|
|
99
|
-
"instanceof",
|
|
100
|
-
"in",
|
|
101
|
-
"of",
|
|
102
|
-
"new",
|
|
103
|
-
"delete",
|
|
104
|
-
"void",
|
|
105
|
-
"throw",
|
|
106
|
-
"case",
|
|
107
|
-
"do",
|
|
108
|
-
"else",
|
|
109
|
-
"yield",
|
|
110
|
-
"await",
|
|
111
|
-
]);
|
|
112
|
-
|
|
113
|
-
const AFTER_A_VALUE = /[)\]}"'`]/;
|
|
114
|
-
|
|
115
|
-
function opensRegex(previous: string): boolean {
|
|
116
|
-
if (previous === "") return true;
|
|
117
|
-
if (WORD.test(previous[0]!)) return KEYWORDS.has(previous);
|
|
118
|
-
return !AFTER_A_VALUE.test(previous);
|
|
10
|
+
export function syntaxFor(path: string): Effect.Effect<Syntax, UnreadableCode> {
|
|
11
|
+
const syntax = syntaxOf(path);
|
|
12
|
+
return syntax ? Effect.succeed(syntax) : Effect.fail(new UnreadableCode({ message: unreadable(path) }));
|
|
119
13
|
}
|
|
120
14
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
let i = start + quote.token.length;
|
|
125
|
-
while (i < source.length) {
|
|
126
|
-
if (quote.backslashEscapes && source[i] === "\\") i += 2;
|
|
127
|
-
else if (source.startsWith(quote.token, i)) return i + quote.token.length;
|
|
128
|
-
else i += 1;
|
|
129
|
-
}
|
|
130
|
-
return start + quote.token.length;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function endOfRegex(source: string, start: number): number {
|
|
134
|
-
let i = start + 1;
|
|
135
|
-
let inClass = false;
|
|
136
|
-
while (i < source.length) {
|
|
137
|
-
const char = source[i]!;
|
|
138
|
-
if (char === "\\") i += 2;
|
|
139
|
-
else if (char === "\n") return i;
|
|
140
|
-
else {
|
|
141
|
-
if (char === "[") inClass = true;
|
|
142
|
-
else if (char === "]") inClass = false;
|
|
143
|
-
else if (char === "/" && !inClass) return i + 1;
|
|
144
|
-
i += 1;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return source.length;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function comments(path: string, source: string): Comment[] {
|
|
151
|
-
const syntax = syntaxFor(path);
|
|
152
|
-
const found: Comment[] = [];
|
|
153
|
-
let i = 0;
|
|
154
|
-
let line = 1;
|
|
155
|
-
let previous = "";
|
|
156
|
-
|
|
157
|
-
const advance = (to: number): void => {
|
|
158
|
-
for (let k = i; k < to; k++) if (source[k] === "\n") line += 1;
|
|
159
|
-
i = to;
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
while (i < source.length) {
|
|
163
|
-
const char = source[i]!;
|
|
164
|
-
|
|
165
|
-
const block = syntax.block.find(([open]) => source.startsWith(open, i));
|
|
166
|
-
if (block) {
|
|
167
|
-
const [open, close] = block;
|
|
168
|
-
const closed = source.indexOf(close, i + open.length);
|
|
169
|
-
const stop = closed < 0 ? source.length : closed + close.length;
|
|
170
|
-
found.push({ line, text: source.slice(i, stop) });
|
|
171
|
-
advance(stop);
|
|
172
|
-
previous = "";
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const marker = syntax.line.find(
|
|
177
|
-
({ token, afterAWordBreak }) =>
|
|
178
|
-
source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source[i - 1]!)),
|
|
179
|
-
);
|
|
180
|
-
if (marker) {
|
|
181
|
-
const newline = source.indexOf("\n", i);
|
|
182
|
-
const stop = newline < 0 ? source.length : newline;
|
|
183
|
-
found.push({ line, text: source.slice(i, stop) });
|
|
184
|
-
advance(stop);
|
|
185
|
-
previous = "";
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const quote = syntax.quotes.find(
|
|
190
|
-
({ token, opensMidWord }) =>
|
|
191
|
-
source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source[i - 1]!)),
|
|
192
|
-
);
|
|
193
|
-
if (quote) {
|
|
194
|
-
advance(endOfQuoted(source, i, quote));
|
|
195
|
-
previous = quote.token;
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
15
|
+
export const comments = Effect.fn("comments")(function* (path: string, source: string) {
|
|
16
|
+
return commentsIn(source, yield* syntaxFor(path));
|
|
17
|
+
});
|
|
198
18
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
if (WORD.test(char)) {
|
|
206
|
-
let end = i;
|
|
207
|
-
while (end < source.length && WORD.test(source[end]!)) end += 1;
|
|
208
|
-
previous = source.slice(i, end);
|
|
209
|
-
advance(end);
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (!/\s/.test(char)) previous = char;
|
|
214
|
-
advance(i + 1);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return found;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const LICENCE = /copyright|spdx-license-identifier|all rights reserved|licen[cs]ed under|permission is hereby granted/i;
|
|
221
|
-
|
|
222
|
-
// A single sentence wraps to at most a few lines at a normal width, so a longer run at the
|
|
223
|
-
// file's own opening is the shape of a paragraph rather than of one fact.
|
|
224
|
-
const A_WRAPPED_SENTENCE_FITS_WITHIN = 3;
|
|
225
|
-
|
|
226
|
-
function beginsItsLine(comment: Comment, lines: readonly string[]): boolean {
|
|
227
|
-
return lines[comment.line - 1]!.trimStart().startsWith(comment.text.split("\n")[0]!);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function openingBlock(found: readonly Comment[], source: string): Comment[] {
|
|
231
|
-
const lines = source.split("\n");
|
|
232
|
-
if (found.length === 0 || found[0]!.line !== 1 || !beginsItsLine(found[0]!, lines)) return [];
|
|
233
|
-
const block = [found[0]!];
|
|
234
|
-
let next = found[0]!.line + found[0]!.text.split("\n").length;
|
|
235
|
-
for (let i = 1; i < found.length && found[i]!.line === next && beginsItsLine(found[i]!, lines); i++) {
|
|
236
|
-
block.push(found[i]!);
|
|
237
|
-
next = found[i]!.line + found[i]!.text.split("\n").length;
|
|
238
|
-
}
|
|
239
|
-
return block;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
type Check = {
|
|
243
|
-
readonly find: RegExp;
|
|
244
|
-
readonly refusal: (match: string) => string;
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
const CHECKS: readonly Check[] = [
|
|
248
|
-
{
|
|
249
|
-
find: /@ts-expect-error|@ts-ignore|\bprettier-ignore\b|\beslint-disable[\w-]*|\bbiome-ignore\b/,
|
|
250
|
-
refusal: (match) =>
|
|
251
|
-
`carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
find: /\bADR[\s-]?\d+|\bdocs\/adr\b|\bRFC[\s-]?\d+|(?:^|[\s(["'`])#\d+\b|[\w.-]+\/[\w.-]+#\d+|\/(?:issues|pull)\/\d+/i,
|
|
255
|
-
refusal: (match) =>
|
|
256
|
-
`points at a record or a ticket (${JSON.stringify(match.trim())}). Drop the pointer: a record is reached by searching docs/adr, and the story of the change goes in the commit message`,
|
|
257
|
-
},
|
|
258
|
-
{
|
|
259
|
-
find: /^\/\*\*/,
|
|
260
|
-
refusal: () =>
|
|
261
|
-
"opens a doc block. Make the code say it, or put what the types cannot hold on a line comment in the fewest words that carry it",
|
|
262
|
-
},
|
|
263
|
-
];
|
|
264
|
-
|
|
265
|
-
function spans(from: number, lineCount: number, within: ReadonlySet<number> | undefined): boolean {
|
|
266
|
-
if (within === undefined) return true;
|
|
267
|
-
for (let line = from; line < from + lineCount; line += 1) if (within.has(line)) return true;
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export function refused(path: string, source: string, within?: ReadonlySet<number>): string[] {
|
|
272
|
-
const out: string[] = [];
|
|
273
|
-
const found = comments(path, source);
|
|
274
|
-
|
|
275
|
-
const opening = openingBlock(found, source);
|
|
276
|
-
const openingText = opening.map((comment) => comment.text).join("\n");
|
|
277
|
-
const openingLines = opening.reduce((sum, comment) => sum + comment.text.split("\n").length, 0);
|
|
278
|
-
if (
|
|
279
|
-
opening.length > 0 &&
|
|
280
|
-
openingLines > A_WRAPPED_SENTENCE_FITS_WITHIN &&
|
|
281
|
-
!LICENCE.test(openingText) &&
|
|
282
|
-
spans(1, openingLines, within)
|
|
283
|
-
) {
|
|
284
|
-
out.push(
|
|
285
|
-
`${path}:1 opens with a ${openingLines}-line rationale block. A record this long belongs in docs/adr or the repo's decision log, and the code does not point at it`,
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
for (const comment of found) {
|
|
290
|
-
if (LICENCE.test(comment.text)) continue;
|
|
291
|
-
if (!spans(comment.line, comment.text.split("\n").length, within)) continue;
|
|
292
|
-
for (const check of CHECKS) {
|
|
293
|
-
const [match] = check.find.exec(comment.text) ?? [];
|
|
294
|
-
if (match !== undefined) out.push(`${path}:${comment.line} ${check.refusal(match)}`);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return out;
|
|
298
|
-
}
|
|
19
|
+
export const refused = Effect.fn("refused")(function* (path: string, source: string, within?: ReadonlySet<number>) {
|
|
20
|
+
return refusalsIn(path, source, yield* syntaxFor(path), within);
|
|
21
|
+
});
|