@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.
@@ -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 { DEFAULT_BRANCH, ENTRY_POINT, KIT_GATES } from "./gates.ts";
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
- function triggerBlocker(workflow: Workflow, branch: string): string | undefined {
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[], branch: string): readonly RunStep[] {
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, triggerBlocker(workflow, branch), [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) => KIT_GATES.some((kitGate) => kitGate.bin === 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
- export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
239
- const steps = runSteps(workflows, declaration.defaultBranch);
240
- return declaration.gates.flatMap((gate) => {
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 formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
263
- const target = `pull requests to ${declaration.defaultBranch}`;
264
- if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
265
- const lines = [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`];
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.join("\n");
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 commands: readonly unknown[] = listed;
286
- const gates: Gate[] = [];
287
- for (const command of commands) {
288
- const words = typeof command === "string" ? plainCommand(command) : undefined;
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 defaultBranch = configured["defaultBranch"] ?? DEFAULT_BRANCH;
297
- if (typeof defaultBranch !== "string" || defaultBranch === "") {
298
- return yield* new WiringError({ message: `${source} ciWiring.defaultBranch is not a branch name` });
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 { gates, defaultBranch };
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 gaps = findGaps(declaration, yield* readWorkflows(root));
340
- if (gaps.length > 0) {
341
- yield* Console.error(formatReport(declaration, gaps));
342
- return false;
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
- yield* Console.log(formatReport(declaration, gaps));
345
- return true;
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);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Option } from "effect";
3
- import { refused, SYNTAXES } from "./comments.ts";
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 path.slice(path.lastIndexOf(".") + 1) in SYNTAXES;
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* parentOf(root, first) : first;
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
+ }