@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.
- package/CHANGELOG.md +21 -0
- package/README.md +9 -0
- package/dependency-cruiser.config.js +2 -1
- package/docs/configs/dependency-rules.md +1 -1
- package/docs/design.md +23 -1
- package/docs/gates/checks-browser.md +201 -0
- package/docs/gates/checks-ci-wiring.md +10 -2
- package/docs/gates/checks-frontend-syntax.md +97 -0
- package/docs/gates/checks-lint.md +3 -1
- package/package.json +37 -4
- package/src/core/gates.ts +3 -0
- package/src/delivery/ci-wiring.ts +34 -38
- package/src/delivery/mutation-job.ts +131 -0
- package/src/delivery/shell-command.ts +69 -5
- package/src/docs/prose-matchers.ts +4 -4
- package/src/docs/sentence-length.ts +2 -2
- package/src/quality/browser/assets.ts +23 -0
- package/src/quality/browser/axe.ts +33 -0
- package/src/quality/browser/browser.ts +134 -0
- package/src/quality/browser/declaration.ts +107 -0
- package/src/quality/browser/dom.ts +121 -0
- package/src/quality/browser/hooks.ts +57 -0
- package/src/quality/browser/keyboard.ts +72 -0
- package/src/quality/browser/layout.ts +208 -0
- package/src/quality/browser/motion.ts +85 -0
- package/src/quality/browser/nesting.ts +23 -0
- package/src/quality/browser/page.ts +66 -0
- package/src/quality/browser/report.ts +48 -0
- package/src/quality/browser/serve.ts +48 -0
- package/src/quality/browser/targets.ts +23 -0
- package/src/quality/frontend-syntax.ts +138 -0
- package/stryker.preset.js +4 -1
|
@@ -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 {
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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 +
|
|
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)
|
|
182
|
-
|
|
183
|
-
|
|
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.!?])[.!?]["'
|
|
339
|
-
const ABBREVIATION =
|
|
340
|
-
const SENTENCE_END = /[.!?:]["'
|
|
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
|
|
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
|
|
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);
|