@avi2dg/checks 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +10 -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-advisories.md +3 -2
- package/docs/gates/checks-browser.md +201 -0
- package/docs/gates/checks-ci-wiring.md +20 -1
- package/docs/gates/checks-flake.md +2 -0
- package/docs/gates/checks-frontend-syntax.md +97 -0
- package/docs/gates/checks-lint.md +3 -1
- package/docs/gates/checks-mutation-compare.md +3 -2
- package/docs/gates/checks-mutation.md +4 -3
- package/docs/gates/checks-release-notes.md +1 -0
- package/docs/gates/checks-release-pr.md +2 -2
- package/package.json +36 -4
- package/src/core/gates.ts +3 -0
- package/src/delivery/ci-wiring.ts +107 -5
- package/src/delivery/shell-command.ts +143 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
"src/docs/prose-matchers.ts",
|
|
55
55
|
"src/quality/comments.ts",
|
|
56
56
|
"src/quality/comment-gate.ts",
|
|
57
|
+
"src/quality/frontend-syntax.ts",
|
|
58
|
+
"src/quality/browser/",
|
|
57
59
|
"src/complexity/suppressions-ratchet.ts",
|
|
58
60
|
"src/complexity/repetition.ts",
|
|
59
61
|
"src/complexity/unused.ts",
|
|
@@ -95,7 +97,8 @@
|
|
|
95
97
|
"./scripts/prose-matchers.ts": "./src/docs/prose-matchers.ts",
|
|
96
98
|
"./stryker.preset.js": "./stryker.preset.js",
|
|
97
99
|
"./templates/*": "./dist/templates/*",
|
|
98
|
-
"./tsconfig.effect.json": "./tsconfig.effect.json"
|
|
100
|
+
"./tsconfig.effect.json": "./tsconfig.effect.json",
|
|
101
|
+
"./browser-hooks.ts": "./src/quality/browser/hooks.ts"
|
|
99
102
|
},
|
|
100
103
|
"bin": {
|
|
101
104
|
"checks-lint": "src/core/lint.ts",
|
|
@@ -122,7 +125,9 @@
|
|
|
122
125
|
"checks-docs": "src/docs/docs.ts",
|
|
123
126
|
"checks-vendor": "src/dependencies/vendor.ts",
|
|
124
127
|
"checks-advisories": "src/dependencies/advisories.ts",
|
|
125
|
-
"checks-secrets": "src/delivery/secrets.ts"
|
|
128
|
+
"checks-secrets": "src/delivery/secrets.ts",
|
|
129
|
+
"checks-frontend-syntax": "src/quality/frontend-syntax.ts",
|
|
130
|
+
"checks-browser": "src/quality/browser/browser.ts"
|
|
126
131
|
},
|
|
127
132
|
"scripts": {
|
|
128
133
|
"prepare": "bun src/dependencies/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
|
|
@@ -134,16 +139,39 @@
|
|
|
134
139
|
"mutate:incremental": "bunx stryker run --incremental"
|
|
135
140
|
},
|
|
136
141
|
"peerDependencies": {
|
|
142
|
+
"@axe-core/playwright": "4.13.0",
|
|
143
|
+
"@effect/tsgo": "0.45.0",
|
|
137
144
|
"@swc/core": "1.16.2",
|
|
138
145
|
"dependency-cruiser": "18.4.0",
|
|
139
|
-
"@effect/tsgo": "0.45.0",
|
|
140
146
|
"effect": "4.0.0",
|
|
147
|
+
"html-validate": "11.16.0",
|
|
141
148
|
"jscpd": "5.3.2",
|
|
142
149
|
"oxlint": "1.83.0",
|
|
143
150
|
"oxlint-tsgolint": "7.0.2002",
|
|
151
|
+
"playwright-core": "1.63.0",
|
|
152
|
+
"postcss-html": "2.0.0",
|
|
153
|
+
"stylelint": "17.16.0",
|
|
144
154
|
"typescript": "7.0.2"
|
|
145
155
|
},
|
|
156
|
+
"peerDependenciesMeta": {
|
|
157
|
+
"@axe-core/playwright": {
|
|
158
|
+
"optional": true
|
|
159
|
+
},
|
|
160
|
+
"html-validate": {
|
|
161
|
+
"optional": true
|
|
162
|
+
},
|
|
163
|
+
"playwright-core": {
|
|
164
|
+
"optional": true
|
|
165
|
+
},
|
|
166
|
+
"postcss-html": {
|
|
167
|
+
"optional": true
|
|
168
|
+
},
|
|
169
|
+
"stylelint": {
|
|
170
|
+
"optional": true
|
|
171
|
+
}
|
|
172
|
+
},
|
|
146
173
|
"devDependencies": {
|
|
174
|
+
"@axe-core/playwright": "4.13.0",
|
|
147
175
|
"@effect/tsgo": "0.45.0",
|
|
148
176
|
"@hughescr/stryker-bun-runner": "1.4.0",
|
|
149
177
|
"@oxlint/plugins": "1.83.0",
|
|
@@ -153,9 +181,13 @@
|
|
|
153
181
|
"@types/commonmark": "0.27.10",
|
|
154
182
|
"dependency-cruiser": "18.4.0",
|
|
155
183
|
"effect": "4.0.0",
|
|
184
|
+
"html-validate": "11.16.0",
|
|
156
185
|
"jscpd": "5.3.2",
|
|
157
186
|
"oxlint": "1.83.0",
|
|
158
187
|
"oxlint-tsgolint": "7.0.2002",
|
|
188
|
+
"playwright-core": "1.63.0",
|
|
189
|
+
"postcss-html": "2.0.0",
|
|
190
|
+
"stylelint": "17.16.0",
|
|
159
191
|
"typescript": "7.0.2"
|
|
160
192
|
},
|
|
161
193
|
"dependencies": {
|
package/src/core/gates.ts
CHANGED
|
@@ -36,11 +36,14 @@ export const LINTED_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx", "*.a
|
|
|
36
36
|
|
|
37
37
|
const BUN_LOCKFILE: TrackedContent = { pathspecs: ["bun.lock"], content: "a bun lockfile" };
|
|
38
38
|
|
|
39
|
+
const FRONTEND_SYNTAX_DECLARATION: TrackedContent = { pathspecs: ["frontend-syntax.json"], content: "a frontend syntax declaration" };
|
|
40
|
+
|
|
39
41
|
export const KIT_GATES = [
|
|
40
42
|
{ bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: LINTED_SOURCE },
|
|
41
43
|
{ bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
42
44
|
{ bin: "checks-commit-identity", vector: "delivery", file: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
43
45
|
{ bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
46
|
+
{ bin: "checks-frontend-syntax", vector: "quality", file: "frontend-syntax.ts", reads: "tree", appliesTo: FRONTEND_SYNTAX_DECLARATION },
|
|
44
47
|
{ bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
45
48
|
{ bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
46
49
|
{ bin: "checks-secrets", vector: "delivery", file: "secrets.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
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 { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
|
|
6
|
+
import { fromProgram, invokes, mentions, plainCommand, shellCommands, withoutOptions, type Command } from "./shell-command.ts";
|
|
7
7
|
|
|
8
8
|
export type { Command };
|
|
9
9
|
|
|
@@ -34,6 +34,18 @@ export type Gap = {
|
|
|
34
34
|
readonly blocked: readonly BlockedInvocation[];
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
export type Visibility = "private" | "public" | "unknown";
|
|
38
|
+
|
|
39
|
+
export type RunnerPolicy = {
|
|
40
|
+
readonly visibility: Visibility;
|
|
41
|
+
readonly scripts: ReadonlyMap<string, string>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type RunnerFault = {
|
|
45
|
+
readonly location: string;
|
|
46
|
+
readonly fault: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
37
49
|
type RunStep = {
|
|
38
50
|
readonly location: string;
|
|
39
51
|
readonly blocker: string | undefined;
|
|
@@ -58,6 +70,17 @@ const CONSTANTS = new Map([
|
|
|
58
70
|
["false", false],
|
|
59
71
|
]);
|
|
60
72
|
const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
|
|
73
|
+
// GitHub expressions match context and property names without regard to case.
|
|
74
|
+
const OVERRIDE_ACCESS = String.raw`vars\s*(?:\.\s*CI_RUNS_ON\b|\[\s*'CI_RUNS_ON'\s*\])`;
|
|
75
|
+
const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
|
|
76
|
+
const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
|
|
77
|
+
const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
|
|
78
|
+
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
|
+
const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
|
|
61
84
|
|
|
62
85
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
86
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -243,13 +266,75 @@ export function declarationFor(scripts: readonly string[], branch: string): Decl
|
|
|
243
266
|
};
|
|
244
267
|
}
|
|
245
268
|
|
|
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
|
+
function sameLabels(runsOn: unknown, labels: readonly string[]): boolean {
|
|
293
|
+
const named = Array.isArray(runsOn) ? names(runsOn) : undefined;
|
|
294
|
+
return named?.length === labels.length && labels.every((label) => named.includes(label));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function readsOverride(runsOn: unknown): boolean {
|
|
298
|
+
if (typeof runsOn === "string") return RUNNER_OVERRIDE.test(runsOn);
|
|
299
|
+
const values = isRecord(runsOn) ? Object.values(runsOn) : Array.isArray(runsOn) ? runsOn : [];
|
|
300
|
+
return values.some(readsOverride);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility): string | undefined {
|
|
304
|
+
const overridden = readsOverride(runsOn);
|
|
305
|
+
const pin = `set runs-on: [${MUTATION_RUNNER.join(", ")}]`;
|
|
306
|
+
if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its full sweeps off winbox; ${pin}`;
|
|
307
|
+
if (mutation) return visibility === "private" && !sameLabels(runsOn, MUTATION_RUNNER) ? `a mutation job in a private repository runs off winbox; ${pin}` : undefined;
|
|
308
|
+
const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
|
|
309
|
+
if (hosted || (visibility !== "private" && !overridden)) return undefined;
|
|
310
|
+
return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
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));
|
|
321
|
+
const fault = runnerFault(job["runs-on"], mutation, policy.visibility);
|
|
322
|
+
return fault === undefined ? [] : [{ location: `${workflow.path} job ${id}`, fault }];
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function formatRunnerReport(faults: readonly RunnerFault[]): string {
|
|
328
|
+
return [`ci-wiring: ${faults.length} job(s) run on the wrong runner:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
246
331
|
export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
|
|
247
332
|
Effect.try({
|
|
248
333
|
try: () => ({ path, document: Bun.YAML.parse(text) }),
|
|
249
334
|
catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
|
|
250
335
|
});
|
|
251
336
|
|
|
252
|
-
|
|
337
|
+
const readScripts = Effect.fn("readScripts")(function* (root: string) {
|
|
253
338
|
const fs = yield* FileSystem.FileSystem;
|
|
254
339
|
const file = (yield* Path.Path).join(root, "package.json");
|
|
255
340
|
const { scripts = {} } = (yield* fs.exists(file))
|
|
@@ -258,7 +343,22 @@ export const readDeclaration = Effect.fn("readDeclaration")(function* (root: str
|
|
|
258
343
|
Effect.mapError((cause) => new WiringError({ message: `cannot read ${file}: ${cause.message}` })),
|
|
259
344
|
)
|
|
260
345
|
: Manifest.make({});
|
|
261
|
-
return
|
|
346
|
+
return new Map(Object.entries(scripts));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
|
|
350
|
+
return declarationFor([...(yield* readScripts(root)).keys()], yield* defaultBranch(root));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Only GitHub's event says whether the repository is private, so a run outside CI judges what holds in either.
|
|
354
|
+
const readVisibility = Effect.gen(function* () {
|
|
355
|
+
const eventPath = yield* Config.String("GITHUB_EVENT_PATH").pipe(Config.withDefault(""));
|
|
356
|
+
if (eventPath === "") return "unknown" satisfies Visibility;
|
|
357
|
+
return yield* (yield* FileSystem.FileSystem).readFileString(eventPath).pipe(
|
|
358
|
+
Effect.flatMap(Schema.decodeUnknownEffect(EventRepository)),
|
|
359
|
+
Effect.map(({ repository }): Visibility => (repository.private ? "private" : "public")),
|
|
360
|
+
Effect.orElseSucceed((): Visibility => "unknown"),
|
|
361
|
+
);
|
|
262
362
|
});
|
|
263
363
|
|
|
264
364
|
export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string) {
|
|
@@ -283,7 +383,9 @@ const wiring = Effect.gen(function* () {
|
|
|
283
383
|
const gaps = findGaps(declaration, workflows);
|
|
284
384
|
const gapReport = formatReport(declaration, gaps);
|
|
285
385
|
yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
|
|
286
|
-
|
|
386
|
+
const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts: yield* readScripts(root) }, workflows);
|
|
387
|
+
if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
|
|
388
|
+
return gaps.length === 0 && faults.length === 0;
|
|
287
389
|
});
|
|
288
390
|
|
|
289
391
|
if (import.meta.main) runMain("ci-wiring", wiring);
|
|
@@ -64,6 +64,149 @@ export function plainCommand(script: string): Command | undefined {
|
|
|
64
64
|
return words.length === 0 ? undefined : words;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
type ShellPart = Span & { readonly substitutions: readonly string[] };
|
|
68
|
+
|
|
69
|
+
const COMMAND_BREAKS = new Set(["\n", ";", "&", "|", "(", ")", "`"]);
|
|
70
|
+
const WORD_BREAKS = new Set([" ", "\t"]);
|
|
71
|
+
const REDIRECTS = new Set(["<", ">"]);
|
|
72
|
+
const REDIRECT_OPERATOR = /^[<>][<>&|]*/;
|
|
73
|
+
const FILE_DESCRIPTOR = /^\d+$/;
|
|
74
|
+
const DOUBLE_QUOTE_ESCAPES = new Set(["$", "`", '"', "\\"]);
|
|
75
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
76
|
+
const RESERVED_WORDS = new Set(["!", "{", "if", "then", "elif", "else", "while", "until", "do"]);
|
|
77
|
+
const LAUNCHER_OPERANDS = new Map<string, ReadonlySet<string>>([
|
|
78
|
+
["time", new Set(["-o", "-f"])],
|
|
79
|
+
["env", new Set(["-u", "-C", "--unset", "--chdir"])],
|
|
80
|
+
["exec", new Set(["-a"])],
|
|
81
|
+
["nohup", new Set()],
|
|
82
|
+
["sudo", new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-U", "--user", "--group", "--chdir", "--host", "--prompt"])],
|
|
83
|
+
["bunx", new Set(["-p", "--package"])],
|
|
84
|
+
["npx", new Set(["-p", "--package", "-c", "--call", "-w", "--workspace"])],
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
function substitutionEnd(script: string, start: number): number {
|
|
88
|
+
if (script.charAt(start) === "`") {
|
|
89
|
+
const close = script.indexOf("`", start + 1);
|
|
90
|
+
return close === -1 ? script.length : close;
|
|
91
|
+
}
|
|
92
|
+
let depth = 0;
|
|
93
|
+
for (let index = start + 1; index < script.length; index += 1) {
|
|
94
|
+
if (script.charAt(index) === "(") depth += 1;
|
|
95
|
+
if (script.charAt(index) === ")") depth -= 1;
|
|
96
|
+
if (depth === 0) return index;
|
|
97
|
+
}
|
|
98
|
+
return script.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function doubleQuotedPart(script: string, index: number): ShellPart {
|
|
102
|
+
const char = script.charAt(index);
|
|
103
|
+
const next = script.charAt(index + 1);
|
|
104
|
+
if (char === "\\" && next === "\n") return { text: "", end: index + 2, substitutions: [] };
|
|
105
|
+
if (char === "\\" && DOUBLE_QUOTE_ESCAPES.has(next)) return { text: next, end: index + 2, substitutions: [] };
|
|
106
|
+
if (char !== "`" && !(char === "$" && next === "(")) return { text: char, end: index + 1, substitutions: [] };
|
|
107
|
+
const end = substitutionEnd(script, index);
|
|
108
|
+
return { text: "", end: end + 1, substitutions: [script.slice(index + (char === "`" ? 1 : 2), end)] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shellDoubleQuoted(script: string, open: number): ShellPart {
|
|
112
|
+
let text = "";
|
|
113
|
+
const substitutions: string[] = [];
|
|
114
|
+
let index = open + 1;
|
|
115
|
+
while (index < script.length && script.charAt(index) !== '"') {
|
|
116
|
+
const part = doubleQuotedPart(script, index);
|
|
117
|
+
text += part.text;
|
|
118
|
+
substitutions.push(...part.substitutions);
|
|
119
|
+
index = part.end;
|
|
120
|
+
}
|
|
121
|
+
return { text, end: index + 1, substitutions };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function shellPart(script: string, index: number): ShellPart {
|
|
125
|
+
const char = script.charAt(index);
|
|
126
|
+
if (char === "'") {
|
|
127
|
+
const close = script.indexOf("'", index + 1);
|
|
128
|
+
const end = close === -1 ? script.length : close;
|
|
129
|
+
return { text: script.slice(index + 1, end), end: end + 1, substitutions: [] };
|
|
130
|
+
}
|
|
131
|
+
if (char === '"') return shellDoubleQuoted(script, index);
|
|
132
|
+
if (char === "\\") return { text: script.charAt(index + 1), end: index + 2, substitutions: [] };
|
|
133
|
+
return { text: char, end: index + 1, substitutions: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function commentEnd(script: string, from: number): number {
|
|
137
|
+
const newline = script.indexOf("\n", from);
|
|
138
|
+
return newline === -1 ? script.length : newline;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type ShellParse = {
|
|
142
|
+
readonly commands: string[][];
|
|
143
|
+
readonly substitutions: string[];
|
|
144
|
+
words: string[];
|
|
145
|
+
word: string | undefined;
|
|
146
|
+
redirecting: boolean;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function endWord(parse: ShellParse): void {
|
|
150
|
+
if (parse.word === undefined) return;
|
|
151
|
+
if (!parse.redirecting) parse.words.push(parse.word);
|
|
152
|
+
parse.redirecting = false;
|
|
153
|
+
parse.word = undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function endCommand(parse: ShellParse): void {
|
|
157
|
+
endWord(parse);
|
|
158
|
+
parse.commands.push(parse.words);
|
|
159
|
+
parse.words = [];
|
|
160
|
+
parse.redirecting = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function startRedirect(parse: ShellParse, script: string, index: number): number {
|
|
164
|
+
if (parse.word !== undefined && FILE_DESCRIPTOR.test(parse.word)) parse.word = undefined;
|
|
165
|
+
endWord(parse);
|
|
166
|
+
parse.redirecting = true;
|
|
167
|
+
return index + (REDIRECT_OPERATOR.exec(script.slice(index))?.[0].length ?? 1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function shellCommands(script: string): readonly Command[] {
|
|
171
|
+
const parse: ShellParse = { commands: [], substitutions: [], words: [], word: undefined, redirecting: false };
|
|
172
|
+
let index = 0;
|
|
173
|
+
while (index < script.length) {
|
|
174
|
+
const char = script.charAt(index);
|
|
175
|
+
if (char === "\\" && script.charAt(index + 1) === "\n") {
|
|
176
|
+
index += 2;
|
|
177
|
+
} else if (char === "#" && parse.word === undefined) {
|
|
178
|
+
index = commentEnd(script, index);
|
|
179
|
+
} else if (REDIRECTS.has(char)) {
|
|
180
|
+
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);
|
|
184
|
+
index += 1;
|
|
185
|
+
} else {
|
|
186
|
+
const part = shellPart(script, index);
|
|
187
|
+
parse.word = (parse.word ?? "") + part.text;
|
|
188
|
+
parse.substitutions.push(...part.substitutions);
|
|
189
|
+
index = part.end;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
endCommand(parse);
|
|
193
|
+
return [...parse.commands, ...parse.substitutions.flatMap(shellCommands)].filter((command) => command.length > 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function withoutOptions(words: Command, operands: ReadonlySet<string>): Command {
|
|
197
|
+
const [first = "", ...rest] = words;
|
|
198
|
+
if (first === "--") return rest;
|
|
199
|
+
if (!first.startsWith("-")) return words;
|
|
200
|
+
return withoutOptions(operands.has(first) ? rest.slice(1) : rest, operands);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function fromProgram(words: Command): Command {
|
|
204
|
+
const [first = "", ...rest] = words;
|
|
205
|
+
if (ASSIGNMENT.test(first) || RESERVED_WORDS.has(first)) return fromProgram(rest);
|
|
206
|
+
const operands = LAUNCHER_OPERANDS.get(first);
|
|
207
|
+
return operands === undefined ? words : fromProgram(withoutOptions(rest, operands));
|
|
208
|
+
}
|
|
209
|
+
|
|
67
210
|
// bun run resolves any other word, even one holding a slash, to a package.json script of that name first.
|
|
68
211
|
const FILE_PATH = /^\.{0,2}\//;
|
|
69
212
|
|
|
@@ -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);
|