@avi2dg/checks 0.34.0 → 0.35.1

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.
@@ -3,7 +3,8 @@ import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { defaultBranch, git } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
6
- import { fromProgram, invokes, mentions, plainCommand, shellCommands, withoutOptions, type Command } from "./shell-command.ts";
6
+ import { readShellFiles, runsMutation, within, type Run } from "./mutation-job.ts";
7
+ import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
7
8
 
8
9
  export type { Command };
9
10
 
@@ -39,6 +40,7 @@ export type Visibility = "private" | "public" | "unknown";
39
40
  export type RunnerPolicy = {
40
41
  readonly visibility: Visibility;
41
42
  readonly scripts: ReadonlyMap<string, string>;
43
+ readonly files?: ReadonlyMap<string, string>;
42
44
  };
43
45
 
44
46
  export type RunnerFault = {
@@ -76,10 +78,6 @@ const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
76
78
  const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
77
79
  const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
78
80
  const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
79
- const MUTATION_BINS = ["checks-mutation", "checks-mutation-compare"];
80
- const BUN_OPERANDS = new Set(["--cwd", "-c", "--config", "--env-file", "-F", "--filter", "-r", "--preload", "--require", "--import", "-e", "--eval", "-p", "--print", "--elide-lines", "--tsconfig-override"]);
81
- // bun's own commands take precedence over a package.json script of the same name unless bun run names it.
82
- const BUN_COMMANDS = new Set(["test", "repl", "exec", "install", "i", "add", "a", "remove", "rm", "update", "outdated", "link", "unlink", "pm", "build", "init", "create", "c", "upgrade", "publish", "patch", "patch-commit", "audit", "info", "why"]);
83
81
  const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
84
82
 
85
83
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -266,29 +264,6 @@ export function declarationFor(scripts: readonly string[], branch: string): Decl
266
264
  };
267
265
  }
268
266
 
269
- function runsMutation(script: string, scripts: ReadonlyMap<string, string>, walked: readonly string[] = []): boolean {
270
- return shellCommands(script).some((command) => mutationCommand(command, scripts, walked));
271
- }
272
-
273
- function mutationCommand(command: Command, scripts: ReadonlyMap<string, string>, walked: readonly string[]): boolean {
274
- const [first = "", ...rest] = fromProgram(command);
275
- return first === "bun" ? bunRunsMutation(rest, scripts, walked) : mutationBin([first, ...rest]);
276
- }
277
-
278
- function bunRunsMutation(args: Command, scripts: ReadonlyMap<string, string>, walked: readonly string[]): boolean {
279
- const [command = "", ...rest] = withoutOptions(args, BUN_OPERANDS);
280
- if (command === "x") return mutationCommand(["bunx", ...rest], scripts, walked);
281
- if (BUN_COMMANDS.has(command)) return false;
282
- const [target = "", ...targetArgs] = command === "run" ? withoutOptions(rest, BUN_OPERANDS) : [command, ...rest];
283
- const body = walked.includes(target) ? undefined : scripts.get(target);
284
- return body === undefined ? mutationBin([target, ...targetArgs]) : runsMutation(body, scripts, [...walked, target]);
285
- }
286
-
287
- function mutationBin([program = "", subcommand]: Command): boolean {
288
- const bin = program.slice(program.lastIndexOf("/") + 1);
289
- return MUTATION_BINS.includes(bin) || (bin === "stryker" && subcommand === "run");
290
- }
291
-
292
267
  function sameLabels(runsOn: unknown, labels: readonly string[]): boolean {
293
268
  const named = Array.isArray(runsOn) ? names(runsOn) : undefined;
294
269
  return named?.length === labels.length && labels.every((label) => named.includes(label));
@@ -310,18 +285,37 @@ function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility)
310
285
  return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
311
286
  }
312
287
 
288
+ function stepDirectory(value: unknown, inherited: string | undefined): string | undefined {
289
+ if (value === undefined) return inherited;
290
+ return typeof value === "string" ? within("", value) : undefined;
291
+ }
292
+
293
+ function defaultDirectory(node: unknown, inherited: string | undefined): string | undefined {
294
+ const defaults = isRecord(node) ? node["defaults"] : undefined;
295
+ const run = isRecord(defaults) ? defaults["run"] : undefined;
296
+ return stepDirectory(isRecord(run) ? run["working-directory"] : undefined, inherited);
297
+ }
298
+
299
+ function jobRuns(workflow: Workflow, job: Readonly<Record<string, unknown>>): readonly Run[] {
300
+ const dir = defaultDirectory(job, defaultDirectory(workflow.document, ""));
301
+ const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
302
+ return steps.flatMap((step: unknown) => (isRecord(step) && typeof step["run"] === "string" ? [{ script: step["run"], dir: stepDirectory(step["working-directory"], dir) }] : []));
303
+ }
304
+
305
+ function workflowJobs(workflow: Workflow): readonly (readonly [string, Readonly<Record<string, unknown>>])[] {
306
+ const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
307
+ return isRecord(jobs) ? Object.entries(jobs).flatMap(([id, job]) => (isRecord(job) ? [[id, job] as const] : [])) : [];
308
+ }
309
+
313
310
  export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workflow[]): readonly RunnerFault[] {
314
- return workflows.flatMap((workflow) => {
315
- const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
316
- if (!isRecord(jobs)) return [];
317
- return Object.entries(jobs).flatMap(([id, job]) => {
318
- if (!isRecord(job) || job["runs-on"] === undefined) return [];
319
- const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
320
- const mutation = steps.some((step: unknown) => isRecord(step) && typeof step["run"] === "string" && runsMutation(step["run"], policy.scripts));
311
+ return workflows.flatMap((workflow) =>
312
+ workflowJobs(workflow).flatMap(([id, job]) => {
313
+ if (job["runs-on"] === undefined) return [];
314
+ const mutation = jobRuns(workflow, job).some((run) => runsMutation(run, policy.scripts, (file) => policy.files?.get(file)));
321
315
  const fault = runnerFault(job["runs-on"], mutation, policy.visibility);
322
316
  return fault === undefined ? [] : [{ location: `${workflow.path} job ${id}`, fault }];
323
- });
324
- });
317
+ }),
318
+ );
325
319
  }
326
320
 
327
321
  export function formatRunnerReport(faults: readonly RunnerFault[]): string {
@@ -383,7 +377,9 @@ const wiring = Effect.gen(function* () {
383
377
  const gaps = findGaps(declaration, workflows);
384
378
  const gapReport = formatReport(declaration, gaps);
385
379
  yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
386
- const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts: yield* readScripts(root) }, workflows);
380
+ const scripts = yield* readScripts(root);
381
+ const runs = workflows.flatMap((workflow) => workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job)));
382
+ const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts, files: yield* readShellFiles(root, scripts, runs) }, workflows);
387
383
  if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
388
384
  return gaps.length === 0 && faults.length === 0;
389
385
  });
@@ -0,0 +1,131 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+ import { fromProgram, SHELLS, shellCommands, withoutOptions, type Command } from "./shell-command.ts";
3
+
4
+ // An undefined directory is one known only at run time.
5
+ export type Run = {
6
+ readonly script: string;
7
+ readonly dir: string | undefined;
8
+ };
9
+
10
+ type Invocation =
11
+ | { readonly script: string; readonly body: string }
12
+ | { readonly program: Command; readonly file: string | undefined; readonly dir: string | undefined };
13
+
14
+ type Walked = {
15
+ readonly scripts: readonly string[];
16
+ readonly files: readonly string[];
17
+ };
18
+
19
+ type Context = {
20
+ readonly scripts: ReadonlyMap<string, string>;
21
+ readonly files: (file: string) => string | undefined;
22
+ };
23
+
24
+ const MUTATION_BINS = ["checks-mutation", "checks-mutation-compare"];
25
+ const BUN_OPERANDS = new Set(["--cwd", "-c", "--config", "--env-file", "-F", "--filter", "-r", "--preload", "--require", "--import", "-e", "--eval", "-p", "--print", "--elide-lines", "--tsconfig-override"]);
26
+ // bun's own commands take precedence over a package.json script of the same name unless bun run names it.
27
+ const BUN_COMMANDS = new Set(["test", "repl", "exec", "install", "i", "add", "a", "remove", "rm", "update", "outdated", "link", "unlink", "pm", "build", "init", "create", "c", "upgrade", "publish", "patch", "patch-commit", "audit", "info", "why"]);
28
+ const SHELL_SHEBANG = new RegExp(String.raw`^#!\s*(?:\S*\/)?(?:env\s+(?:-\S+\s+)*)?(?:${SHELLS.join("|")})(?:\s|$)`);
29
+ const UNRESOLVED = /^[/~]|[$`]/;
30
+ const ROOT = "";
31
+
32
+ export function within(dir: string | undefined, path: string): string | undefined {
33
+ if (dir === undefined || UNRESOLVED.test(path)) return undefined;
34
+ const resolved = dir === ROOT ? [] : dir.split("/");
35
+ for (const part of path.split("/")) {
36
+ if (part === ".." && resolved.length === 0) return undefined;
37
+ if (part === "..") resolved.pop();
38
+ else if (part !== "" && part !== ".") resolved.push(part);
39
+ }
40
+ return resolved.join("/");
41
+ }
42
+
43
+ export function runsMutation(run: Run, scripts: ReadonlyMap<string, string>, files: (file: string) => string | undefined): boolean {
44
+ return scriptRunsMutation(run.script, run.dir, { scripts, files }, { scripts: [], files: [] });
45
+ }
46
+
47
+ function scriptRunsMutation(script: string, dir: string | undefined, context: Context, walked: Walked): boolean {
48
+ let cwd = dir;
49
+ for (const command of shellCommands(script)) {
50
+ const [program = "", ...args] = fromProgram(command);
51
+ if (program === "cd") cwd = changedDirectory(cwd, withoutOptions(args, new Set()));
52
+ else if (mutationCommand(command, cwd, context, walked)) return true;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ function changedDirectory(cwd: string | undefined, [target]: Command): string | undefined {
58
+ return target === undefined || target === "-" ? undefined : within(cwd, target);
59
+ }
60
+
61
+ function bunOptions(words: Command): Command {
62
+ return words.slice(0, words.length - withoutOptions(words, BUN_OPERANDS).length);
63
+ }
64
+
65
+ function bunDirectory(options: Command, dir: string | undefined): string | undefined {
66
+ return options.reduce<string | undefined>((current, word, index) => {
67
+ const value = word === "--cwd" ? options[index + 1] : word.startsWith("--cwd=") ? word.slice("--cwd=".length) : undefined;
68
+ return value === undefined ? current : within(dir, value);
69
+ }, dir);
70
+ }
71
+
72
+ function fileAt(dir: string | undefined, path: string): string | undefined {
73
+ return within(dir, path) || undefined;
74
+ }
75
+
76
+ function invocation(command: Command, dir: string | undefined, context: Context, walked: Walked): Invocation | undefined {
77
+ const [first = "", ...rest] = fromProgram(command);
78
+ if (first !== "bun") return { program: [first, ...rest], file: first.includes("/") ? fileAt(dir, first) : undefined, dir };
79
+ const [subcommand = "", ...operands] = withoutOptions(rest, BUN_OPERANDS);
80
+ if (subcommand === "x") return invocation(["bunx", ...operands], dir, context, walked);
81
+ if (BUN_COMMANDS.has(subcommand)) return undefined;
82
+ const [target = "", ...targetArgs] = subcommand === "run" ? withoutOptions(operands, BUN_OPERANDS) : [subcommand, ...operands];
83
+ const cwd = bunDirectory([...bunOptions(rest), ...(subcommand === "run" ? bunOptions(operands) : [])], dir);
84
+ const body = walked.scripts.includes(target) ? undefined : context.scripts.get(target);
85
+ return body === undefined ? { program: [target, ...targetArgs], file: fileAt(cwd, target), dir: cwd } : { script: target, body };
86
+ }
87
+
88
+ function mutationCommand(command: Command, dir: string | undefined, context: Context, walked: Walked): boolean {
89
+ const call = invocation(command, dir, context, walked);
90
+ if (call === undefined) return false;
91
+ // bun runs a script from the directory of the package.json that names it.
92
+ if ("body" in call) return scriptRunsMutation(call.body, ROOT, context, { ...walked, scripts: [...walked.scripts, call.script] });
93
+ return mutationBin(call.program) || fileRunsMutation(call.file, call.dir, context, walked);
94
+ }
95
+
96
+ // A script file runs in its caller's directory, not its own.
97
+ function fileRunsMutation(file: string | undefined, dir: string | undefined, context: Context, walked: Walked): boolean {
98
+ if (file === undefined || walked.files.includes(file)) return false;
99
+ const body = context.files(file);
100
+ return body !== undefined && scriptRunsMutation(body, dir, context, { ...walked, files: [...walked.files, file] });
101
+ }
102
+
103
+ function mutationBin([program = "", subcommand]: Command): boolean {
104
+ const bin = program.slice(program.lastIndexOf("/") + 1);
105
+ return MUTATION_BINS.includes(bin) || (bin === "stryker" && subcommand === "run");
106
+ }
107
+
108
+ function filesWanted(runs: readonly Run[], scripts: ReadonlyMap<string, string>, files: ReadonlyMap<string, string>, read: ReadonlySet<string>): readonly string[] {
109
+ const wanted = new Set<string>();
110
+ const lookup = (file: string) => {
111
+ if (!read.has(file)) wanted.add(file);
112
+ return files.get(file);
113
+ };
114
+ for (const run of runs) runsMutation(run, scripts, lookup);
115
+ return [...wanted];
116
+ }
117
+
118
+ export const readShellFiles = Effect.fn("readShellFiles")(function* (root: string, scripts: ReadonlyMap<string, string>, runs: readonly Run[]) {
119
+ const fs = yield* FileSystem.FileSystem;
120
+ const path = yield* Path.Path;
121
+ const contents = new Map<string, string>();
122
+ const read = new Set<string>();
123
+ for (let wanted = filesWanted(runs, scripts, contents, read); wanted.length > 0; wanted = filesWanted(runs, scripts, contents, read)) {
124
+ for (const file of wanted) {
125
+ read.add(file);
126
+ const text = yield* fs.readFileString(path.join(root, file)).pipe(Effect.orElseSucceed(() => undefined));
127
+ if (text !== undefined && (file.endsWith(".sh") || SHELL_SHEBANG.test(text))) contents.set(file, text);
128
+ }
129
+ }
130
+ return contents;
131
+ });
@@ -70,6 +70,7 @@ const COMMAND_BREAKS = new Set(["\n", ";", "&", "|", "(", ")", "`"]);
70
70
  const WORD_BREAKS = new Set([" ", "\t"]);
71
71
  const REDIRECTS = new Set(["<", ">"]);
72
72
  const REDIRECT_OPERATOR = /^[<>][<>&|]*/;
73
+ const DELIMITER_BREAKS = new Set([...WORD_BREAKS, ...COMMAND_BREAKS, ...REDIRECTS]);
73
74
  const FILE_DESCRIPTOR = /^\d+$/;
74
75
  const DOUBLE_QUOTE_ESCAPES = new Set(["$", "`", '"', "\\"]);
75
76
  const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
@@ -138,9 +139,19 @@ function commentEnd(script: string, from: number): number {
138
139
  return newline === -1 ? script.length : newline;
139
140
  }
140
141
 
142
+ export const SHELLS = ["sh", "bash", "dash", "ksh", "zsh"];
143
+
144
+ type Heredoc = {
145
+ readonly consumer: Command;
146
+ readonly delimiter: string;
147
+ readonly tabs: boolean;
148
+ readonly literal: boolean;
149
+ };
150
+
141
151
  type ShellParse = {
142
152
  readonly commands: string[][];
143
153
  readonly substitutions: string[];
154
+ readonly heredocs: Heredoc[];
144
155
  words: string[];
145
156
  word: string | undefined;
146
157
  redirecting: boolean;
@@ -163,12 +174,63 @@ function endCommand(parse: ShellParse): void {
163
174
  function startRedirect(parse: ShellParse, script: string, index: number): number {
164
175
  if (parse.word !== undefined && FILE_DESCRIPTOR.test(parse.word)) parse.word = undefined;
165
176
  endWord(parse);
177
+ const operator = REDIRECT_OPERATOR.exec(script.slice(index))?.[0] ?? script.charAt(index);
178
+ if (operator === "<<") return startHeredoc(parse, script, index + operator.length);
166
179
  parse.redirecting = true;
167
- return index + (REDIRECT_OPERATOR.exec(script.slice(index))?.[0].length ?? 1);
180
+ return index + operator.length;
181
+ }
182
+
183
+ function startHeredoc(parse: ShellParse, script: string, from: number): number {
184
+ const tabs = script.charAt(from) === "-";
185
+ let start = tabs ? from + 1 : from;
186
+ while (WORD_BREAKS.has(script.charAt(start))) start += 1;
187
+ let delimiter = "";
188
+ let end = start;
189
+ while (end < script.length && !DELIMITER_BREAKS.has(script.charAt(end))) {
190
+ const part = shellPart(script, end);
191
+ delimiter += part.text;
192
+ end = part.end;
193
+ }
194
+ parse.heredocs.push({ consumer: parse.words, delimiter, tabs, literal: /['"\\]/.test(script.slice(start, end)) });
195
+ return end;
196
+ }
197
+
198
+ function heredocBody(script: string, from: number, { delimiter, tabs }: Heredoc): Span {
199
+ let line = from;
200
+ while (line < script.length) {
201
+ const lineEnd = commentEnd(script, line);
202
+ const text = script.slice(line, lineEnd);
203
+ if ((tabs ? text.replace(/^\t+/, "") : text) === delimiter) return { text: script.slice(from, line), end: lineEnd + 1 };
204
+ line = lineEnd + 1;
205
+ }
206
+ return { text: script.slice(from), end: script.length };
207
+ }
208
+
209
+ function bodySubstitutions(body: string): readonly string[] {
210
+ const substitutions: string[] = [];
211
+ let index = 0;
212
+ while (index < body.length) {
213
+ const part = doubleQuotedPart(body, index);
214
+ substitutions.push(...part.substitutions);
215
+ index = part.end;
216
+ }
217
+ return substitutions;
218
+ }
219
+
220
+ function skipHeredocs(parse: ShellParse, script: string, from: number): number {
221
+ let index = from;
222
+ for (const heredoc of parse.heredocs.splice(0)) {
223
+ const body = heredocBody(script, index, heredoc);
224
+ const [program = ""] = fromProgram(heredoc.consumer);
225
+ if (SHELLS.includes(program.slice(program.lastIndexOf("/") + 1))) parse.substitutions.push(body.text);
226
+ else if (!heredoc.literal) parse.substitutions.push(...bodySubstitutions(body.text));
227
+ index = body.end;
228
+ }
229
+ return index;
168
230
  }
169
231
 
170
232
  export function shellCommands(script: string): readonly Command[] {
171
- const parse: ShellParse = { commands: [], substitutions: [], words: [], word: undefined, redirecting: false };
233
+ const parse: ShellParse = { commands: [], substitutions: [], heredocs: [], words: [], word: undefined, redirecting: false };
172
234
  let index = 0;
173
235
  while (index < script.length) {
174
236
  const char = script.charAt(index);
@@ -178,9 +240,11 @@ export function shellCommands(script: string): readonly Command[] {
178
240
  index = commentEnd(script, index);
179
241
  } else if (REDIRECTS.has(char)) {
180
242
  index = startRedirect(parse, script, index);
181
- } else if (COMMAND_BREAKS.has(char) || WORD_BREAKS.has(char)) {
182
- if (COMMAND_BREAKS.has(char)) endCommand(parse);
183
- else endWord(parse);
243
+ } else if (COMMAND_BREAKS.has(char)) {
244
+ endCommand(parse);
245
+ index = char === "\n" ? skipHeredocs(parse, script, index + 1) : index + 1;
246
+ } else if (WORD_BREAKS.has(char)) {
247
+ endWord(parse);
184
248
  index += 1;
185
249
  } else {
186
250
  const part = shellPart(script, index);
@@ -335,9 +335,9 @@ function ruleFindings(line: MarkdownLine, reader: Reader): ProseFinding[] {
335
335
  );
336
336
  }
337
337
 
338
- const SENTENCE_BREAK = /(?<=[^\s.!?])[.!?]["'”’)\]*_]*\s+(?=[A-Z"“*_[\0])/g;
339
- const ABBREVIATION = /\b(?:e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St|No|Fig)$/i;
340
- const SENTENCE_END = /[.!?:]["'”’)\]*_]*\s*$/;
338
+ const SENTENCE_BREAK = /(?<=[^\s.!?])[.!?]["'”’»)\]*_]*\s+(?=[\p{Lu}"“«*_[\0])/gu;
339
+ const ABBREVIATION = /(?<![\p{L}\p{N}_])(?:e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St|No|Fig|см|напр|т\.\s*[едп]|др|стр)$/iu;
340
+ const SENTENCE_END = /[.!?:]["'”’»)\]*_]*\s*$/;
341
341
  const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
342
342
  const QUOTE_DEPTH = /^(?:\s*>)*/;
343
343
 
@@ -355,7 +355,7 @@ function secondSentence(line: MarkdownLine): ProseFinding | undefined {
355
355
  return undefined;
356
356
  }
357
357
 
358
- const OPENS_LOWERCASE = /^\s*[a-z]/;
358
+ const OPENS_LOWERCASE = /^\s*\p{Ll}/u;
359
359
 
360
360
  // A sentence may end inside the code or link that closes its line, so only a lowercase next line proves it runs on.
361
361
  function endsMidSentence(line: MarkdownLine, next: MarkdownLine): boolean {
@@ -38,7 +38,7 @@ function orderedAt(raw: string): boolean {
38
38
  }
39
39
 
40
40
  function isWordChar(char: string): boolean {
41
- return (char >= "0" && char <= "9") || (char >= "A" && char <= "Z") || (char >= "a" && char <= "z");
41
+ return /[\p{L}\p{N}]/u.test(char);
42
42
  }
43
43
 
44
44
  function wordsIn(sentence: string): number {
@@ -59,7 +59,7 @@ function wordsIn(sentence: string): number {
59
59
  }
60
60
 
61
61
  function isCloser(char: string): boolean {
62
- return char === '"' || char === "'" || char === ")" || char === "]" || char === "*" || char === "_" || char === "”" || char === "’";
62
+ return char === '"' || char === "'" || char === ")" || char === "]" || char === "*" || char === "_" || char === "”" || char === "’" || char === "»";
63
63
  }
64
64
 
65
65
  function sentencesIn(body: string): readonly string[] {
@@ -0,0 +1,23 @@
1
+ import type { Page } from "playwright-core";
2
+
3
+ const FAILING_STATUS = 400;
4
+
5
+ export type AssetWatch = {
6
+ readonly requested: () => number;
7
+ readonly found: () => readonly string[];
8
+ };
9
+
10
+ export function watchAssets(page: Page): AssetWatch {
11
+ const found: string[] = [];
12
+ let requested = 0;
13
+ page.on("request", () => {
14
+ requested += 1;
15
+ });
16
+ page.on("requestfailed", (request) => {
17
+ found.push(`${request.url()} fails to load: ${request.failure()?.errorText ?? "no reason given"}`);
18
+ });
19
+ page.on("response", (response) => {
20
+ if (response.status() >= FAILING_STATUS) found.push(`${response.url()} answers ${response.status()}`);
21
+ });
22
+ return { requested: () => requested, found: () => [...found] };
23
+ }
@@ -0,0 +1,33 @@
1
+ import { Effect } from "effect";
2
+ import type { Page } from "playwright-core";
3
+ import { attempt } from "./page.ts";
4
+
5
+ const WCAG_AA = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
6
+
7
+ const CONTRAST = "color-contrast";
8
+
9
+ type AxeReport = {
10
+ readonly found: readonly string[];
11
+ readonly unverified: readonly string[];
12
+ readonly rules: number;
13
+ };
14
+
15
+ type Node = { readonly target: readonly unknown[]; readonly any: readonly { readonly message: string }[] };
16
+
17
+ function where(node: Node): string {
18
+ return node.target.map(String).join(" ");
19
+ }
20
+
21
+ export const judgeAxe = Effect.fn("judgeAxe")(function* (page: Page) {
22
+ const { AxeBuilder } = yield* attempt("cannot load @axe-core/playwright, which a product that runs the axe check installs beside the kit", () =>
23
+ import("@axe-core/playwright"),
24
+ );
25
+ const results = yield* attempt("axe cannot analyse the page", () => new AxeBuilder({ page }).withTags(WCAG_AA).analyze());
26
+ const found = results.violations.flatMap((violation) =>
27
+ violation.nodes.map((node) => `${violation.id} (${violation.impact ?? "unknown"}): ${where(node)}: ${violation.help}`),
28
+ );
29
+ const unverified = results.incomplete
30
+ .filter(({ id }) => id === CONTRAST)
31
+ .flatMap(({ nodes }) => nodes.map((node) => `${CONTRAST} unverified: ${where(node)}: ${node.any[0]?.message ?? "axe cannot measure it"}`));
32
+ return { found, unverified, rules: results.passes.length + results.violations.length + results.incomplete.length } satisfies AxeReport;
33
+ });
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, Path } from "effect";
3
+ import type { Browser } from "playwright-core";
4
+ import { runMain } from "../../core/main.ts";
5
+ import { watchAssets } from "./assets.ts";
6
+ import { judgeAxe } from "./axe.ts";
7
+ import { readDeclaration, targetsOn, visitsOf, type CheckName, type Declaration, type Visit } from "./declaration.ts";
8
+ import { loadHooks, runHook, type ProductCheck } from "./hooks.ts";
9
+ import { judgeKeyboard } from "./keyboard.ts";
10
+ import { judgeLayout } from "./layout.ts";
11
+ import { judgeStillness, recordMotion } from "./motion.ts";
12
+ import { judgeNesting } from "./nesting.ts";
13
+ import { attempt, launchChrome, load, openVisit, type BrowserFailure } from "./page.ts";
14
+ import { counted, NAME, passes, report, type Run } from "./report.ts";
15
+ import { readSite, serveSite } from "./serve.ts";
16
+ import { judgeTargets } from "./targets.ts";
17
+
18
+ const NO_TARGET = "scanned no target";
19
+
20
+ type Visiting = {
21
+ readonly browser: Browser;
22
+ readonly declaration: Declaration;
23
+ readonly visit: Visit;
24
+ readonly url: string;
25
+ };
26
+
27
+ const run = (check: string, visit: Visit, inventory: readonly string[], found: readonly string[], unverified: readonly string[] = []): Run => ({
28
+ check,
29
+ visit,
30
+ inventory,
31
+ found,
32
+ unverified,
33
+ });
34
+
35
+ const targetsRun = Effect.fn("targetsRun")(function* ({ browser, declaration, visit, url }: Visiting) {
36
+ const targets = targetsOn(declaration, visit.route.path);
37
+ const { page } = yield* openVisit(browser, visit);
38
+ yield* load(page, url);
39
+ const { found, visible } = yield* judgeTargets(page, targets);
40
+ const inventory = [...visible].map(([name, count]) => `${name} ${count}`);
41
+ return run("targets", visit, inventory, targets.length === 0 ? [...found, NO_TARGET] : found);
42
+ }, Effect.scoped);
43
+
44
+ const layoutRun = Effect.fn("layoutRun")(function* ({ browser, declaration, visit, url }: Visiting) {
45
+ const { cdp, page } = yield* openVisit(browser, visit);
46
+ yield* load(page, url);
47
+ const { found, scanned, parked } = yield* judgeLayout(cdp, targetsOn(declaration, visit.route.path));
48
+ const inventory = [...counted(scanned.map(({ name }) => name)), ...counted(parked).map((one) => `${one} parked until focused`)];
49
+ return run("layout", visit, inventory, scanned.length === 0 ? [...found, NO_TARGET] : found);
50
+ }, Effect.scoped);
51
+
52
+ const keyboardRun = Effect.fn("keyboardRun")(function* ({ browser, declaration, visit, url }: Visiting) {
53
+ const targets = targetsOn(declaration, visit.route.path).filter(({ focusable }) => focusable === true);
54
+ const { page } = yield* openVisit(browser, visit);
55
+ yield* load(page, url);
56
+ const { found, reached } = yield* judgeKeyboard(page, targets);
57
+ const inventory = [...reached].map(([name, times]) => `${name} ${times}`);
58
+ return run("keyboard", visit, inventory, targets.length === 0 || reached.size === 0 ? [...found, NO_TARGET] : found);
59
+ }, Effect.scoped);
60
+
61
+ const motionRun = Effect.fn("motionRun")(function* ({ browser, visit, url }: Visiting) {
62
+ const { page } = yield* openVisit(browser, visit, recordMotion);
63
+ yield* load(page, url);
64
+ const { found, controls, unhovered } = yield* judgeStillness(page);
65
+ const inventory = [`${controls} control(s)`, ...(unhovered === 0 ? [] : [`${unhovered} it cannot hover, judged on focus alone`])];
66
+ return run("motion", visit, inventory, controls === 0 ? [...found, "scanned no control"] : found);
67
+ }, Effect.scoped);
68
+
69
+ const axeRun = Effect.fn("axeRun")(function* ({ browser, visit, url }: Visiting) {
70
+ const { page } = yield* openVisit(browser, visit);
71
+ yield* load(page, url);
72
+ const { found, unverified, rules } = yield* judgeAxe(page);
73
+ return run("axe", visit, [`${rules} rule(s)`], rules === 0 ? [...found, "axe ran no rule"] : found, unverified);
74
+ }, Effect.scoped);
75
+
76
+ const nestingRun = Effect.fn("nestingRun")(function* ({ browser, visit, url }: Visiting) {
77
+ const { page } = yield* openVisit(browser, visit);
78
+ yield* load(page, url);
79
+ const { found, elements } = yield* judgeNesting(page);
80
+ return run("nesting", visit, [`${elements} element(s)`], found);
81
+ }, Effect.scoped);
82
+
83
+ const assetsRun = Effect.fn("assetsRun")(function* ({ browser, visit, url }: Visiting) {
84
+ const { page } = yield* openVisit(browser, visit);
85
+ const watch = watchAssets(page);
86
+ yield* attempt(`cannot load ${url}`, () => page.goto(url, { waitUntil: "networkidle" }));
87
+ return run("assets", visit, [`${watch.requested()} request(s)`], watch.found());
88
+ }, Effect.scoped);
89
+
90
+ const hookRun = Effect.fn("hookRun")(function* (check: ProductCheck, { browser, visit, url }: Visiting) {
91
+ const { page } = yield* openVisit(browser, visit);
92
+ const { targets, found } = yield* runHook(check, page, { ...visit, url });
93
+ return run(`hook ${check.name}`, visit, counted(targets), targets.length === 0 ? [...found, NO_TARGET] : found);
94
+ }, Effect.scoped);
95
+
96
+ const BUILT_IN: Readonly<Record<CheckName, (visiting: Visiting) => Effect.Effect<Run, BrowserFailure>>> = {
97
+ layout: layoutRun,
98
+ keyboard: keyboardRun,
99
+ motion: motionRun,
100
+ axe: axeRun,
101
+ nesting: nestingRun,
102
+ assets: assetsRun,
103
+ };
104
+
105
+ function applies(check: CheckName, { state }: Visit): boolean {
106
+ return check !== "motion" || state.reducedMotion === "reduce";
107
+ }
108
+
109
+ const checkSite = Effect.fn("checkSite")(function* (root: string, declaration: Declaration) {
110
+ const site = yield* readSite(root, declaration.site);
111
+ const paths = declaration.routes.map(({ path }) => path);
112
+ const hooks = declaration.hooks === undefined ? [] : yield* loadHooks(root, declaration.hooks, paths);
113
+ const missingRoutes = paths.filter((path) => !site.files.has(path));
114
+ const origin = yield* serveSite(site);
115
+ const browser = yield* launchChrome;
116
+ const runs: Run[] = [];
117
+ for (const visit of visitsOf(declaration).filter(({ route }) => !missingRoutes.includes(route.path))) {
118
+ const visiting = { browser, declaration, visit, url: new URL(visit.route.path, origin).href };
119
+ runs.push(yield* targetsRun(visiting));
120
+ for (const check of declaration.checks.filter((one) => applies(one, visit))) runs.push(yield* BUILT_IN[check](visiting));
121
+ for (const hook of hooks.filter(({ routes }) => routes === undefined || routes.includes(visit.route.path))) runs.push(yield* hookRun(hook, visiting));
122
+ }
123
+ return { runs, missingRoutes };
124
+ }, Effect.scoped);
125
+
126
+ const browserChecks = Effect.gen(function* () {
127
+ const root = (yield* Path.Path).resolve(process.argv[2] ?? ".");
128
+ const declaration = yield* readDeclaration(root);
129
+ const outcome = yield* checkSite(root, declaration);
130
+ yield* Console.log(report(declaration, outcome));
131
+ return passes(outcome);
132
+ });
133
+
134
+ if (import.meta.main) runMain(NAME, browserChecks);