@avi2dg/checks 0.8.0 → 0.10.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 +253 -29
- package/oxlintrc.json +2 -2
- package/package.json +15 -3
- package/scripts/backtest.ts +2 -3
- package/scripts/ci-wiring.ts +134 -36
- package/scripts/comment-gate.ts +4 -7
- package/scripts/comment-matchers.ts +309 -0
- package/scripts/comments.ts +7 -295
- package/scripts/flake.ts +153 -0
- package/scripts/gates.ts +48 -8
- package/scripts/git.ts +14 -0
- package/scripts/lint-coverage.sh +11 -5
- package/scripts/lint.ts +44 -27
- package/scripts/suppressions-ratchet.ts +10 -5
- package/scripts/test-layout.ts +4 -4
- package/scripts/test-report.ts +115 -0
- package/scripts/test.ts +153 -0
package/scripts/ci-wiring.ts
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { git } from "./git.ts";
|
|
4
4
|
import { runMain } from "./main.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_BRANCH,
|
|
7
|
+
ENTRY_POINT,
|
|
8
|
+
EVERY_REPOSITORY,
|
|
9
|
+
KIT_GATES,
|
|
10
|
+
LintWiring,
|
|
11
|
+
selectedGates,
|
|
12
|
+
type KitGate,
|
|
13
|
+
} from "./gates.ts";
|
|
6
14
|
|
|
7
15
|
export type Command = readonly string[];
|
|
8
16
|
|
|
@@ -13,7 +21,9 @@ export type Gate = {
|
|
|
13
21
|
|
|
14
22
|
export type Declaration = {
|
|
15
23
|
readonly gates: readonly Gate[];
|
|
24
|
+
readonly scheduled: readonly Gate[];
|
|
16
25
|
readonly defaultBranch: string;
|
|
26
|
+
readonly lintGates: readonly KitGate[];
|
|
17
27
|
};
|
|
18
28
|
|
|
19
29
|
export type Workflow = {
|
|
@@ -26,6 +36,12 @@ export type BlockedInvocation = {
|
|
|
26
36
|
readonly blocker: string;
|
|
27
37
|
};
|
|
28
38
|
|
|
39
|
+
export type Omission = {
|
|
40
|
+
readonly gate: string;
|
|
41
|
+
readonly content: string;
|
|
42
|
+
readonly files: readonly [string, ...string[]];
|
|
43
|
+
};
|
|
44
|
+
|
|
29
45
|
export type Gap = {
|
|
30
46
|
readonly gate: string;
|
|
31
47
|
readonly entryPoint?: string;
|
|
@@ -38,6 +54,8 @@ type RunStep = {
|
|
|
38
54
|
readonly script: string;
|
|
39
55
|
};
|
|
40
56
|
|
|
57
|
+
type Trigger = (workflow: Workflow) => string | undefined;
|
|
58
|
+
|
|
41
59
|
export class WiringError extends Schema.TaggedError<WiringError>()("WiringError", {
|
|
42
60
|
message: Schema.String,
|
|
43
61
|
}) {}
|
|
@@ -146,7 +164,7 @@ function selects(patterns: readonly string[], branch: string): boolean {
|
|
|
146
164
|
return selected;
|
|
147
165
|
}
|
|
148
166
|
|
|
149
|
-
|
|
167
|
+
const pullRequestTrigger = (branch: string): Trigger => (workflow) => {
|
|
150
168
|
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
151
169
|
const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
|
|
152
170
|
if (!events.includes("pull_request")) return `${workflow.path} does not trigger on pull_request`;
|
|
@@ -168,7 +186,14 @@ function triggerBlocker(workflow: Workflow, branch: string): string | undefined
|
|
|
168
186
|
const missing = types === undefined ? [] : GATING_TYPES.filter((type) => !types.includes(type));
|
|
169
187
|
if (missing.length > 0) return `${workflow.path} limits pull_request to types without ${missing.join(", ")}`;
|
|
170
188
|
return undefined;
|
|
171
|
-
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const scheduleTrigger: Trigger = (workflow) => {
|
|
192
|
+
const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
|
|
193
|
+
const schedule = isRecord(on) ? on["schedule"] : undefined;
|
|
194
|
+
const crons = Array.isArray(schedule) ? schedule.filter((entry) => isRecord(entry) && typeof entry["cron"] === "string") : [];
|
|
195
|
+
return crons.length > 0 ? undefined : `${workflow.path} does not trigger on a schedule`;
|
|
196
|
+
};
|
|
172
197
|
|
|
173
198
|
// GitHub prefixes any other if: with success(), so only a status function overrides a needed job's skip.
|
|
174
199
|
function skippedBy(jobs: Readonly<Record<string, unknown>>, id: string, seen: readonly string[]): string | undefined {
|
|
@@ -187,7 +212,7 @@ function localCall(uses: unknown): string | undefined {
|
|
|
187
212
|
return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
|
|
188
213
|
}
|
|
189
214
|
|
|
190
|
-
function runSteps(workflows: readonly Workflow[],
|
|
215
|
+
function runSteps(workflows: readonly Workflow[], trigger: Trigger): readonly RunStep[] {
|
|
191
216
|
const documents = new Map(workflows.map((workflow) => [workflow.path, workflow.document]));
|
|
192
217
|
const steps: RunStep[] = [];
|
|
193
218
|
|
|
@@ -225,20 +250,19 @@ function runSteps(workflows: readonly Workflow[], branch: string): readonly RunS
|
|
|
225
250
|
};
|
|
226
251
|
|
|
227
252
|
for (const workflow of workflows) {
|
|
228
|
-
visit(workflow.document, workflow.path,
|
|
253
|
+
visit(workflow.document, workflow.path, trigger(workflow), [workflow.path]);
|
|
229
254
|
}
|
|
230
255
|
return steps;
|
|
231
256
|
}
|
|
232
257
|
|
|
233
|
-
function entryPointCommand(gate: Command): Command | undefined {
|
|
234
|
-
const index = gate.findIndex((word) =>
|
|
258
|
+
function entryPointCommand(gate: Command, lintGates: readonly KitGate[]): Command | undefined {
|
|
259
|
+
const index = gate.findIndex((word) => lintGates.some((kitGate) => kitGate.bin === word));
|
|
235
260
|
return index === -1 ? undefined : [...gate.slice(0, index), ENTRY_POINT.bin];
|
|
236
261
|
}
|
|
237
262
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const entryPoint = entryPointCommand(gate.words);
|
|
263
|
+
function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates: readonly KitGate[]): readonly Gap[] {
|
|
264
|
+
return gates.flatMap((gate) => {
|
|
265
|
+
const entryPoint = entryPointCommand(gate.words, lintGates);
|
|
242
266
|
const commands = [
|
|
243
267
|
{ command: gate.command, words: gate.words },
|
|
244
268
|
...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
|
|
@@ -259,10 +283,16 @@ export function findGaps(declaration: Declaration, workflows: readonly Workflow[
|
|
|
259
283
|
});
|
|
260
284
|
}
|
|
261
285
|
|
|
262
|
-
export function
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
286
|
+
export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
|
|
287
|
+
return gapsAmong(declaration.gates, runSteps(workflows, pullRequestTrigger(declaration.defaultBranch)), declaration.lintGates);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function findScheduledGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
|
|
291
|
+
return gapsAmong(declaration.scheduled, runSteps(workflows, scheduleTrigger), declaration.lintGates);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function gapLines(gaps: readonly Gap[]): readonly string[] {
|
|
295
|
+
const lines: string[] = [];
|
|
266
296
|
for (const gap of gaps) {
|
|
267
297
|
lines.push(` ${gap.gate}`);
|
|
268
298
|
if (gap.blocked.length === 0) {
|
|
@@ -270,9 +300,38 @@ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): st
|
|
|
270
300
|
}
|
|
271
301
|
for (const { location, blocker } of gap.blocked) lines.push(` ${location}: ${blocker}`);
|
|
272
302
|
}
|
|
273
|
-
return lines
|
|
303
|
+
return lines;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const decodeWiring = Schema.decodeUnknownEffect(LintWiring);
|
|
307
|
+
|
|
308
|
+
export function formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
|
|
309
|
+
const target = `pull requests to ${declaration.defaultBranch}`;
|
|
310
|
+
if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
|
|
311
|
+
return [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`, ...gapLines(gaps)].join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function formatScheduledReport(declaration: Declaration, gaps: readonly Gap[]): string {
|
|
315
|
+
const total = declaration.scheduled.length;
|
|
316
|
+
if (gaps.length === 0) return `ci-wiring: ${total} scheduled command(s) run on a schedule`;
|
|
317
|
+
return [`ci-wiring: ${gaps.length} of ${total} scheduled command(s) do not run on a schedule:`, ...gapLines(gaps)].join("\n");
|
|
274
318
|
}
|
|
275
319
|
|
|
320
|
+
const plainCommands = Effect.fnUntraced(function* (
|
|
321
|
+
listed: readonly unknown[],
|
|
322
|
+
subject: string,
|
|
323
|
+
): Effect.fn.Return<readonly Gate[], WiringError> {
|
|
324
|
+
const gates: Gate[] = [];
|
|
325
|
+
for (const command of listed) {
|
|
326
|
+
const words = typeof command === "string" ? plainCommand(command) : undefined;
|
|
327
|
+
if (typeof command !== "string" || words === undefined) {
|
|
328
|
+
return yield* new WiringError({ message: `${subject} ${JSON.stringify(command)} is not one plain command` });
|
|
329
|
+
}
|
|
330
|
+
gates.push({ command, words });
|
|
331
|
+
}
|
|
332
|
+
return gates;
|
|
333
|
+
});
|
|
334
|
+
|
|
276
335
|
export const parseDeclaration = Effect.fnUntraced(function* (
|
|
277
336
|
manifest: unknown,
|
|
278
337
|
source: string,
|
|
@@ -282,24 +341,56 @@ export const parseDeclaration = Effect.fnUntraced(function* (
|
|
|
282
341
|
if (!Array.isArray(listed) || listed.length === 0) {
|
|
283
342
|
return yield* new WiringError({ message: `${source} sets no ciWiring.gates, a non-empty array of commands` });
|
|
284
343
|
}
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (typeof command !== "string" || words === undefined) {
|
|
290
|
-
return yield* new WiringError({
|
|
291
|
-
message: `${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`,
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
gates.push({ command, words });
|
|
344
|
+
const gates = yield* plainCommands(listed, `${source} ciWiring gate`);
|
|
345
|
+
const scheduledListed = configured["scheduled"] ?? [];
|
|
346
|
+
if (!Array.isArray(scheduledListed)) {
|
|
347
|
+
return yield* new WiringError({ message: `${source} ciWiring.scheduled is not an array of commands` });
|
|
295
348
|
}
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
349
|
+
const scheduled = yield* plainCommands(scheduledListed, `${source} ciWiring scheduled command`);
|
|
350
|
+
const { ciWiring } = yield* decodeWiring(manifest).pipe(
|
|
351
|
+
Effect.mapError((cause) => new WiringError({ message: `${source}: ${cause.message}` })),
|
|
352
|
+
);
|
|
353
|
+
return {
|
|
354
|
+
gates,
|
|
355
|
+
scheduled,
|
|
356
|
+
defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH,
|
|
357
|
+
lintGates: selectedGates(ciWiring?.lintGates),
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
function omittedFrom(lintGates: readonly KitGate[]): readonly KitGate[] {
|
|
362
|
+
return KIT_GATES.filter((gate) => !lintGates.includes(gate));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export const findOmissions = Effect.fn("findOmissions")(function* (root: string, lintGates: readonly KitGate[]) {
|
|
366
|
+
const omissions: Omission[] = [];
|
|
367
|
+
for (const gate of omittedFrom(lintGates)) {
|
|
368
|
+
// LintGates refuses a selection that leaves out a gate every repository runs.
|
|
369
|
+
if (gate.appliesTo === EVERY_REPOSITORY) continue;
|
|
370
|
+
const { pathspecs, content } = gate.appliesTo;
|
|
371
|
+
const [first, ...rest] = (yield* git(["ls-files", "-z", "--", ...pathspecs], root)).split("\0").filter(Boolean);
|
|
372
|
+
if (first !== undefined) omissions.push({ gate: gate.bin, content, files: [first, ...rest] });
|
|
299
373
|
}
|
|
300
|
-
return
|
|
374
|
+
return omissions;
|
|
301
375
|
});
|
|
302
376
|
|
|
377
|
+
function sample([first, ...rest]: Omission["files"]): string {
|
|
378
|
+
return rest.length === 0 ? first : `${first} and ${rest.length} more`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function formatOmissions(lintGates: readonly KitGate[], omissions: readonly Omission[]): string | undefined {
|
|
382
|
+
const omitted = omittedFrom(lintGates);
|
|
383
|
+
if (omitted.length === 0) return undefined;
|
|
384
|
+
if (omissions.length === 0) {
|
|
385
|
+
const bins = omitted.map((gate) => gate.bin).join(", ");
|
|
386
|
+
return `ci-wiring: ciWiring.lintGates leaves out ${bins}, none of which this repository's contents make applicable`;
|
|
387
|
+
}
|
|
388
|
+
return [
|
|
389
|
+
`ci-wiring: ciWiring.lintGates leaves out ${omissions.length} gate(s) this repository's contents make applicable:`,
|
|
390
|
+
...omissions.map(({ gate, content, files }) => ` ${gate}: the repository tracks ${content} (${sample(files)})`),
|
|
391
|
+
].join("\n");
|
|
392
|
+
}
|
|
393
|
+
|
|
303
394
|
export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
|
|
304
395
|
Effect.try({
|
|
305
396
|
try: () => ({ path, document: Bun.YAML.parse(text) }),
|
|
@@ -336,13 +427,20 @@ export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string)
|
|
|
336
427
|
const wiring = Effect.gen(function* () {
|
|
337
428
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
338
429
|
const declaration = yield* readDeclaration(root);
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
430
|
+
const workflows = yield* readWorkflows(root);
|
|
431
|
+
const gaps = findGaps(declaration, workflows);
|
|
432
|
+
const omissions = yield* findOmissions(root, declaration.lintGates);
|
|
433
|
+
const gapReport = formatReport(declaration, gaps);
|
|
434
|
+
yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
|
|
435
|
+
const omissionReport = formatOmissions(declaration.lintGates, omissions);
|
|
436
|
+
if (omissionReport !== undefined) {
|
|
437
|
+
yield* omissions.length > 0 ? Console.error(omissionReport) : Console.log(omissionReport);
|
|
343
438
|
}
|
|
344
|
-
|
|
345
|
-
|
|
439
|
+
if (declaration.scheduled.length === 0) return gaps.length === 0 && omissions.length === 0;
|
|
440
|
+
const scheduledGaps = findScheduledGaps(declaration, workflows);
|
|
441
|
+
const scheduledReport = formatScheduledReport(declaration, scheduledGaps);
|
|
442
|
+
yield* scheduledGaps.length > 0 ? Console.error(scheduledReport) : Console.log(scheduledReport);
|
|
443
|
+
return gaps.length === 0 && omissions.length === 0 && scheduledGaps.length === 0;
|
|
346
444
|
});
|
|
347
445
|
|
|
348
446
|
if (import.meta.main) runMain("ci-wiring", wiring);
|
package/scripts/comment-gate.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
|
-
import { refused,
|
|
4
|
-
import { git } from "./git.ts";
|
|
3
|
+
import { refused, syntaxOf } from "./comments.ts";
|
|
4
|
+
import { git, parentOrEmptyTree } from "./git.ts";
|
|
5
5
|
import { runMain, Usage } from "./main.ts";
|
|
6
6
|
|
|
7
7
|
export type GateResult = {
|
|
@@ -13,7 +13,7 @@ export type GateResult = {
|
|
|
13
13
|
const USAGE = "usage: comment-gate.ts <ref> | <base-ref> <head-ref>";
|
|
14
14
|
|
|
15
15
|
function readable(path: string): boolean {
|
|
16
|
-
return
|
|
16
|
+
return syntaxOf(path) !== undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function newPathOf(line: string): string | undefined {
|
|
@@ -83,15 +83,12 @@ export function report({ files, addedLines, violations }: GateResult): string {
|
|
|
83
83
|
return [`comment-gate: ${violations.length} violation(s):`, ...violations.map((one) => ` ${one}`)].join("\n");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
const parentOf = (root: string, rev: string) =>
|
|
87
|
-
git(["rev-parse", "--verify", `${rev}^`], root).pipe(Effect.map((parent) => parent.trim()));
|
|
88
|
-
|
|
89
86
|
const gate = Effect.gen(function* () {
|
|
90
87
|
const [first, second, ...extra] = process.argv.slice(2);
|
|
91
88
|
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
92
89
|
|
|
93
90
|
const root = (yield* git(["rev-parse", "--show-toplevel"], process.cwd())).trim();
|
|
94
|
-
const base = second === undefined ? yield*
|
|
91
|
+
const base = second === undefined ? yield* parentOrEmptyTree(first, root) : first;
|
|
95
92
|
const result = yield* runRange(root, base, second ?? first);
|
|
96
93
|
|
|
97
94
|
yield* Console.log(report(result));
|
|
@@ -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
|
+
}
|