@avi2dg/checks 0.42.0 → 0.44.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -2
  3. package/{commitlint.config.js → commitlint.config.ts} +1 -1
  4. package/dist/mutation/mutation-guard-plugin.js +40 -0
  5. package/dist/mutation/mutation-refusal.js +23 -0
  6. package/docs/configs/commit-messages.md +2 -2
  7. package/docs/design.md +20 -0
  8. package/docs/gates/checks-ci-wiring.md +3 -2
  9. package/docs/gates/checks-copy.md +113 -0
  10. package/docs/gates/checks-docs-only.md +3 -2
  11. package/docs/gates/checks-docs.md +3 -0
  12. package/docs/gates/checks-flake.md +49 -1
  13. package/docs/gates/checks-lint.md +2 -1
  14. package/docs/gates/checks-mutation-baseline.md +4 -2
  15. package/docs/gates/checks-mutation-compare.md +33 -8
  16. package/docs/gates/checks-mutation-scope.md +140 -0
  17. package/docs/gates/checks-mutation.md +11 -7
  18. package/docs/gates/checks-no-plain-js.md +60 -0
  19. package/docs/gates/checks-test-layout.md +3 -2
  20. package/docs/gates/checks-test.md +35 -6
  21. package/package.json +18 -6
  22. package/src/core/declared-inputs.ts +76 -0
  23. package/src/core/gates.ts +4 -0
  24. package/src/core/git.ts +9 -0
  25. package/src/delivery/ci-wiring.ts +25 -11
  26. package/src/delivery/commit-title.ts +2 -2
  27. package/src/docs/copy-html.ts +110 -0
  28. package/src/docs/copy-text.ts +70 -0
  29. package/src/docs/copy.ts +70 -0
  30. package/src/docs/prose-matchers.ts +14 -14
  31. package/src/quality/frontend-syntax.ts +9 -52
  32. package/src/quality/no-plain-js.ts +71 -0
  33. package/src/testing/flake-preload.ts +4 -0
  34. package/src/testing/flake.ts +142 -16
  35. package/src/testing/{mutation-scope.js → mutation-refusal.ts} +11 -9
  36. package/src/testing/mutation-scope.ts +354 -0
  37. package/src/testing/mutation.ts +1 -1
  38. package/src/testing/test-layout.ts +8 -15
  39. package/src/testing/test-report.ts +20 -5
  40. package/src/testing/test-skips.ts +1 -1
  41. package/src/testing/test.ts +53 -11
  42. package/stryker.preset.js +3 -3
  43. package/src/testing/mutation-guard-plugin.js +0 -13
@@ -0,0 +1,110 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { HtmlElement, HtmlValidate, Rule, StaticConfigLoader, TextNode, staticResolver, type DOMReadyEvent, type Plugin } from "html-validate";
3
+ import { collecting, separatorFindings, type Collector, type CopyFinding, type Place } from "./copy-text.ts";
4
+
5
+ const PLUGIN = "copy";
6
+ const RULE = `${PLUGIN}/separators`;
7
+
8
+ const UNREAD: ReadonlySet<string> = new Set(["script", "style", "template", "pre"]);
9
+ const CODE: ReadonlySet<string> = new Set(["code", "kbd", "samp", "var"]);
10
+ const TEXT_ATTRIBUTES = ["alt", "title", "placeholder", "aria-label", "aria-description"] as const;
11
+ const BUTTON_TYPES: ReadonlySet<string> = new Set(["submit", "button", "reset"]);
12
+ const LABELLED: ReadonlySet<string> = new Set(["option", "optgroup"]);
13
+ const META_COPY: ReadonlySet<string> = new Set(["description", "og:title", "og:description", "twitter:title", "twitter:description"]);
14
+
15
+ // Code stands in as one character that is neither a letter nor a space, so a hyphen after it still reads as spaced.
16
+ const HIDDEN = "\0";
17
+
18
+ const NAMED: Readonly<Record<string, string>> = { mdash: "—", ndash: "–", nbsp: "\u00a0" };
19
+ const ENTITY = /^&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([A-Za-z][A-Za-z0-9]{1,31}));/;
20
+ const LARGEST_CODE_POINT = 0x10ffff;
21
+
22
+ function decoded([, decimal, hex, name]: RegExpExecArray): string {
23
+ const point = decimal === undefined ? (hex === undefined ? undefined : Number.parseInt(hex, 16)) : Number(decimal);
24
+ if (point === undefined) return NAMED[name ?? ""] ?? HIDDEN;
25
+ return point > 0 && point <= LARGEST_CODE_POINT ? String.fromCodePoint(point) : HIDDEN;
26
+ }
27
+
28
+ function addSource(collector: Collector, source: string, from: Place): void {
29
+ let { line, column } = from;
30
+ let at = 0;
31
+ while (at < source.length) {
32
+ const entity = source.charAt(at) === "&" ? ENTITY.exec(source.slice(at)) : null;
33
+ const char = entity === null ? source.charAt(at) : decoded(entity);
34
+ const length = entity === null ? 1 : entity[0].length;
35
+ collector.add(char, { line, column });
36
+ if (char === "\n") {
37
+ line += 1;
38
+ column = 1;
39
+ } else column += length;
40
+ at += length;
41
+ }
42
+ }
43
+
44
+ function addAttribute(element: HtmlElement, key: string, attributes: Collector): void {
45
+ const attribute = element.getAttribute(key);
46
+ if (attribute === null || typeof attribute.value !== "string" || attribute.valueLocation === null) return;
47
+ addSource(attributes, attribute.value, attribute.valueLocation);
48
+ attributes.end();
49
+ }
50
+
51
+ function readAttributes(element: HtmlElement, attributes: Collector): void {
52
+ for (const key of TEXT_ATTRIBUTES) addAttribute(element, key, attributes);
53
+ const named = element.getAttributeValue("name") ?? element.getAttributeValue("property");
54
+ if (element.tagName === "meta" && named !== null && META_COPY.has(named)) addAttribute(element, "content", attributes);
55
+ const type = element.getAttributeValue("type")?.toLowerCase();
56
+ if (element.tagName === "input" && type !== undefined && BUTTON_TYPES.has(type)) addAttribute(element, "value", attributes);
57
+ if (LABELLED.has(element.tagName)) addAttribute(element, "label", attributes);
58
+ }
59
+
60
+ function readElement(element: HtmlElement, text: Collector, attributes: Collector): void {
61
+ readAttributes(element, attributes);
62
+ const inline = element.meta?.phrasing === true && element.tagName !== "br";
63
+ if (!inline) text.end();
64
+ if (CODE.has(element.tagName)) text.add(HIDDEN, element.location);
65
+ else if (!UNREAD.has(element.tagName)) readChildren(element, text, attributes);
66
+ if (!inline) text.end();
67
+ }
68
+
69
+ function readChildren(element: HtmlElement, text: Collector, attributes: Collector): void {
70
+ for (const child of element.childNodes) {
71
+ if (child instanceof TextNode) addSource(text, child.textContent, child.location);
72
+ else if (child instanceof HtmlElement) readElement(child, text, attributes);
73
+ }
74
+ }
75
+
76
+ class Separators extends Rule {
77
+ override setup(): void {
78
+ this.on("dom:ready", ({ document, source }: DOMReadyEvent) => {
79
+ const text = collecting();
80
+ const attributes = collecting();
81
+ readChildren(document.root, text, attributes);
82
+ for (const { line, column, message } of separatorFindings([...text.blocks(), ...attributes.blocks()])) {
83
+ this.report(null, message, { filename: source.filename, line, column, offset: 0, size: 1 });
84
+ }
85
+ });
86
+ }
87
+ }
88
+
89
+ const plugin: Plugin = { name: PLUGIN, rules: { [RULE]: Separators } };
90
+
91
+ const validator = new HtmlValidate(
92
+ new StaticConfigLoader([staticResolver({ plugins: { [PLUGIN]: plugin } })], {
93
+ root: true,
94
+ elements: ["html5", { svg: { foreign: false }, math: { foreign: false } }],
95
+ plugins: [PLUGIN],
96
+ rules: { [RULE]: "error" },
97
+ }),
98
+ );
99
+
100
+ class CopyHtmlError extends Schema.TaggedError<CopyHtmlError>()("CopyHtmlError", { message: Schema.String }) {}
101
+
102
+ export const htmlFindings = Effect.fn("htmlFindings")(function* (source: string, file: string) {
103
+ const { results } = yield* Effect.tryPromise({
104
+ try: () => validator.validateString(source, file),
105
+ catch: (cause) => new CopyHtmlError({ message: `html-validate cannot read ${file}: ${String(cause)}` }),
106
+ });
107
+ return results.flatMap(({ messages }) =>
108
+ messages.map(({ line, column, message, ruleId }) => ({ line, column, message: ruleId === RULE ? message : `does not parse as HTML: ${message}` })),
109
+ ) satisfies readonly CopyFinding[];
110
+ });
@@ -0,0 +1,70 @@
1
+ import { bodyOf, carries, continues, scanMarkdown, SEPARATORS, type LineKind } from "./prose-matchers.ts";
2
+
3
+ export type Place = { readonly line: number; readonly column: number };
4
+
5
+ export type CopyFinding = Place & { readonly message: string };
6
+
7
+ // Each place is the source position of the character at the same index in text.
8
+ type Block = { readonly text: string; readonly places: readonly Place[] };
9
+
10
+ export type Collector = {
11
+ readonly add: (char: string, place: Place) => void;
12
+ readonly end: () => void;
13
+ readonly blocks: () => readonly Block[];
14
+ };
15
+
16
+ const SPACE = /\s/;
17
+
18
+ // A reader sees each run of white space as one space, so a hyphen wrapped onto the next line still reads as spaced.
19
+ export function collecting(): Collector {
20
+ const done: Block[] = [];
21
+ let text = "";
22
+ let places: Place[] = [];
23
+ const add = (char: string, place: Place): void => {
24
+ const spaced = SPACE.test(char);
25
+ if (spaced && (text === "" || text.endsWith(" "))) return;
26
+ text += spaced ? " " : char;
27
+ places.push(place);
28
+ };
29
+ const end = (): void => {
30
+ if (text.trim() !== "") done.push({ text, places });
31
+ text = "";
32
+ places = [];
33
+ };
34
+ return {
35
+ add,
36
+ end,
37
+ blocks: () => {
38
+ end();
39
+ return done;
40
+ },
41
+ };
42
+ }
43
+
44
+ function blockFindings({ text, places }: Block): readonly CopyFinding[] {
45
+ return SEPARATORS.flatMap((rule) =>
46
+ [...text.matchAll(rule.find)].flatMap((match) => {
47
+ const place = places[match.index + match[0].length - match[0].trimStart().length];
48
+ return place === undefined ? [] : [{ ...place, message: carries(match[0], rule) }];
49
+ }),
50
+ );
51
+ }
52
+
53
+ export function separatorFindings(blocks: readonly Block[]): readonly CopyFinding[] {
54
+ return blocks.flatMap(blockFindings).toSorted((a, b) => a.line - b.line || a.column - b.column);
55
+ }
56
+
57
+ const READ_KINDS: ReadonlySet<LineKind> = new Set(["prose", "heading", "table", "html"]);
58
+
59
+ export function markdownFindings(text: string): readonly CopyFinding[] {
60
+ const lines = scanMarkdown(text);
61
+ const collector = collecting();
62
+ for (const [index, line] of lines.entries()) {
63
+ if (!continues(lines[index - 1], line)) collector.end();
64
+ if (!READ_KINDS.has(line.kind)) continue;
65
+ const body = bodyOf(line);
66
+ for (let at = 0; at < body.length; at += 1) collector.add(body.charAt(at), { line: line.line, column: at + 1 });
67
+ collector.add("\n", { line: line.line, column: body.length + 1 });
68
+ }
69
+ return separatorFindings(collector.blocks());
70
+ }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bun
2
+ import { Effect, FileSystem, Path, Schema } from "effect";
3
+ import { emptyInputs, inputsReport, runDeclaredGate, violationLine, type Declared, type Input, type Violation } from "../core/declared-inputs.ts";
4
+ import { Usage } from "../core/main.ts";
5
+ import { markdownFindings } from "./copy-text.ts";
6
+
7
+ const DECLARED: Declared = { name: "copy", file: "copy.json", declares: "copy" };
8
+
9
+ const MARKDOWN = ".md";
10
+ const HTML = ".html";
11
+
12
+ const OUTSIDE = /(?:^|\/)node_modules\//;
13
+
14
+ export type Scan = {
15
+ readonly inputs: readonly Input[];
16
+ readonly unread: readonly string[];
17
+ readonly violations: readonly Violation[];
18
+ };
19
+
20
+ class CopyError extends Schema.TaggedError<CopyError>()("CopyError", {
21
+ message: Schema.String,
22
+ }) {}
23
+
24
+ function isCopyFile(file: string): boolean {
25
+ return file.endsWith(MARKDOWN) || file.endsWith(HTML);
26
+ }
27
+
28
+ const matched = (root: string, glob: string) =>
29
+ Effect.tryPromise({
30
+ try: () => Array.fromAsync(new Bun.Glob(glob).scan({ cwd: root, onlyFiles: true })),
31
+ catch: (cause) => new CopyError({ message: `cannot expand ${glob}: ${String(cause)}` }),
32
+ }).pipe(Effect.map((files) => files.filter((file) => !OUTSIDE.test(file)).toSorted()));
33
+
34
+ const loadHtml = Effect.tryPromise({
35
+ try: () => import("./copy-html.ts"),
36
+ catch: () => new Usage({ message: `cannot load html-validate, which a repository that declares HTML in ${DECLARED.file} installs beside the kit as an optional peer` }),
37
+ });
38
+
39
+ const judge = Effect.fn("judge")(function* (root: string, file: string) {
40
+ const source = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, file));
41
+ const findings = file.endsWith(HTML) ? yield* (yield* loadHtml).htmlFindings(source, file) : markdownFindings(source);
42
+ return findings.map(({ line, column, message }) => ({ file, line, column, text: message }) satisfies Violation);
43
+ });
44
+
45
+ export const scanInputs = Effect.fn("scanInputs")(function* (root: string, globs: readonly string[]) {
46
+ const inputs: Input[] = [];
47
+ const unread = new Set<string>();
48
+ for (const glob of globs) {
49
+ const files = yield* matched(root, glob);
50
+ inputs.push({ glob, files: files.filter(isCopyFile) });
51
+ for (const other of files.filter((file) => !isCopyFile(file))) unread.add(other);
52
+ }
53
+ const judged = [...new Set(inputs.flatMap(({ files }) => files))].toSorted();
54
+ const violations = yield* Effect.forEach(judged, (file) => judge(root, file));
55
+ return { inputs, unread: [...unread].toSorted(), violations: violations.flat() } satisfies Scan;
56
+ });
57
+
58
+ export function passes({ inputs, unread, violations }: Scan): boolean {
59
+ return violations.length === 0 && unread.length === 0 && emptyInputs(inputs).length === 0;
60
+ }
61
+
62
+ export function report({ inputs, unread, violations }: Scan): string {
63
+ return inputsReport(DECLARED.name, inputs, [
64
+ ...emptyInputs(inputs).map(({ glob }) => `${glob}: the declared input matches no Markdown or HTML file`),
65
+ ...unread.map((file) => `${file}: is neither Markdown nor HTML, so the gate cannot read it`),
66
+ ...violations.map(violationLine),
67
+ ]);
68
+ }
69
+
70
+ if (import.meta.main) runDeclaredGate(DECLARED, scanInputs, { report, passes });
@@ -20,7 +20,7 @@ export type ProseRule = {
20
20
  readonly instead: string;
21
21
  };
22
22
 
23
- type MatchedRule = ProseRule & { readonly find: RegExp };
23
+ export type MatchedRule = ProseRule & { readonly find: RegExp };
24
24
 
25
25
  export const ADR_DIRECTORY = "docs/adr/";
26
26
  export const DOCS_DIRECTORY = "docs/";
@@ -268,9 +268,15 @@ const code = (text: string): string => `\`${text}\``;
268
268
  const EVERY_READER: readonly Reader[] = ["people", "agents"];
269
269
  const PEOPLE: readonly Reader[] = ["people"];
270
270
 
271
+ const EM_DASH: MatchedRule = { readers: EVERY_READER, refuses: "an em dash", example: code("—"), instead: SEPARATOR_INSTEAD, find: /—/g };
272
+ const EN_DASH: MatchedRule = { readers: EVERY_READER, refuses: "an en dash", example: code("–"), instead: SEPARATOR_INSTEAD, find: /–/g };
273
+ const HYPHEN_AS_DASH: MatchedRule = { readers: EVERY_READER, refuses: "a hyphen used as a dash", example: `${code("a - b")} or ${code("a -- b")}`,
274
+ instead: SEPARATOR_INSTEAD, find: /(?<=[^\s|]) -{1,3} (?=[^\s|])/g };
275
+ export const SEPARATORS: readonly MatchedRule[] = [EM_DASH, EN_DASH, HYPHEN_AS_DASH];
276
+
271
277
  const RULES: readonly MatchedRule[] = [
272
- { readers: EVERY_READER, refuses: "an em dash", example: code("—"), instead: SEPARATOR_INSTEAD, find: /—/g },
273
- { readers: EVERY_READER, refuses: "an en dash", example: code("–"), instead: SEPARATOR_INSTEAD, find: /–/g },
278
+ EM_DASH,
279
+ EN_DASH,
274
280
  {
275
281
  readers: EVERY_READER,
276
282
  refuses: "a parenthesis other than the plural `(s)`",
@@ -278,13 +284,7 @@ const RULES: readonly MatchedRule[] = [
278
284
  instead: "Make the aside its own sentence, or set it off with commas",
279
285
  find: /\((?!s\))/g,
280
286
  },
281
- {
282
- readers: EVERY_READER,
283
- refuses: "a hyphen used as a dash",
284
- example: `${code("a - b")} or ${code("a -- b")}`,
285
- instead: SEPARATOR_INSTEAD,
286
- find: /(?<=[^\s|]) -{1,3} (?=[^\s|])/g,
287
- },
287
+ HYPHEN_AS_DASH,
288
288
  { readers: EVERY_READER, refuses: "a semicolon", example: code(";"), instead: "Use two sentences", find: /;/g },
289
289
  {
290
290
  readers: PEOPLE,
@@ -328,11 +328,11 @@ export const PROSE_RULES: readonly ProseRule[] = [
328
328
 
329
329
  const JUDGED_KINDS: ReadonlySet<LineKind> = new Set(["prose", "heading", "table", "html"]);
330
330
 
331
+ export const carries = (match: string, { refuses, instead }: ProseRule): string => `carries \`${match.trim()}\`, ${refuses}. ${instead}`;
332
+
331
333
  function ruleFindings(line: MarkdownLine, reader: Reader): ProseFinding[] {
332
334
  const body = bodyOf(line);
333
- return RULES.filter(({ readers }) => readers.includes(reader)).flatMap(({ refuses, instead, find }) =>
334
- [...body.matchAll(find)].map(([match]) => ({ line: line.line, message: `carries \`${match.trim()}\`, ${refuses}. ${instead}` })),
335
- );
335
+ return RULES.filter(({ readers }) => readers.includes(reader)).flatMap((rule) => [...body.matchAll(rule.find)].map(([match]) => ({ line: line.line, message: carries(match, rule) })));
336
336
  }
337
337
 
338
338
  const SENTENCE_BREAK = /(?<=[^\s.!?])[.!?]["'”’»)\]*_]*\s+(?=[\p{Lu}"“«*_[\0])/gu;
@@ -364,7 +364,7 @@ function endsMidSentence(line: MarkdownLine, next: MarkdownLine): boolean {
364
364
  return !body.endsWith(HIDDEN) || OPENS_LOWERCASE.test(bodyOf(next));
365
365
  }
366
366
 
367
- function continues(earlier: MarkdownLine | undefined, later: MarkdownLine | undefined): boolean {
367
+ export function continues(earlier: MarkdownLine | undefined, later: MarkdownLine | undefined): boolean {
368
368
  if (earlier?.kind !== "prose" || later?.kind !== "prose" || LIST_ITEM.test(later.raw)) return false;
369
369
  return QUOTE_DEPTH.exec(earlier.raw)?.[0].split(">").length === QUOTE_DEPTH.exec(later.raw)?.[0].split(">").length;
370
370
  }
@@ -1,11 +1,10 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
+ import { Effect, Path, Schema } from "effect";
3
3
  import type { LintResult } from "stylelint";
4
- import { runMain, Usage } from "../core/main.ts";
4
+ import { emptyInputs, inputsReport, runDeclaredGate, violationLine, type Declared, type Input, type Violation } from "../core/declared-inputs.ts";
5
+ import { Usage } from "../core/main.ts";
5
6
 
6
- const NAME = "frontend-syntax";
7
-
8
- const DECLARATION = "frontend-syntax.json";
7
+ const DECLARED: Declared = { name: "frontend-syntax", file: "frontend-syntax.json", declares: "inputs" };
9
8
 
10
9
  const TRANSITION_ALL = "declaration-property-value-disallowed-list";
11
10
  const BODY_WIDE_USER_SELECT = "rule-selector-property-disallowed-list";
@@ -24,22 +23,6 @@ const ADVICE: Readonly<Record<string, string>> = {
24
23
 
25
24
  const MARKUP = ["**/*.html", "**/*.astro", "**/*.vue", "**/*.svelte"];
26
25
 
27
- const Declaration = Schema.Struct({ inputs: Schema.NonEmptyArray(Schema.NonEmptyString) });
28
-
29
- const decodeDeclaration = Schema.decodeUnknownEffect(Schema.fromJsonString(Declaration));
30
-
31
- export type Violation = {
32
- readonly file: string;
33
- readonly line: number;
34
- readonly column: number;
35
- readonly text: string;
36
- };
37
-
38
- export type Input = {
39
- readonly glob: string;
40
- readonly files: readonly string[];
41
- };
42
-
43
26
  export type Scan = {
44
27
  readonly inputs: readonly Input[];
45
28
  readonly violations: readonly Violation[];
@@ -59,20 +42,9 @@ const loadPeer = <A>(name: string, load: () => Promise<A>) =>
59
42
  Effect.tryPromise({
60
43
  try: load,
61
44
  catch: () =>
62
- new Usage({ message: `cannot load ${name}, which a repository that declares ${DECLARATION} installs beside the kit as an optional peer` }),
45
+ new Usage({ message: `cannot load ${name}, which a repository that declares ${DECLARED.file} installs beside the kit as an optional peer` }),
63
46
  });
64
47
 
65
- const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
66
- const fs = yield* FileSystem.FileSystem;
67
- const file = (yield* Path.Path).join(root, DECLARATION);
68
- if (!(yield* fs.exists(file))) {
69
- return yield* new Usage({ message: `${DECLARATION} is missing, and a repository opts in to this gate by declaring its inputs there` });
70
- }
71
- return yield* decodeDeclaration(yield* fs.readFileString(file)).pipe(
72
- Effect.mapError(({ message }) => new Usage({ message: `${DECLARATION} does not decode: ${message}` })),
73
- );
74
- });
75
-
76
48
  function violationsOf(result: LintResult, relative: (file: string) => string): readonly Violation[] {
77
49
  const file = relative(result.source ?? "");
78
50
  return result.warnings.map(({ line, column, rule, text }) => ({
@@ -112,27 +84,12 @@ export const scanInputs = Effect.fn("scanInputs")(function* (root: string, globs
112
84
  });
113
85
 
114
86
  export function passes({ inputs, violations }: Scan): boolean {
115
- return violations.length === 0 && inputs.every(({ files }) => files.length > 0);
87
+ return violations.length === 0 && emptyInputs(inputs).length === 0;
116
88
  }
117
89
 
118
90
  export function report({ inputs, violations }: Scan): string {
119
- const files = new Set(inputs.flatMap((input) => input.files)).size;
120
- const scanned = `${files} file(s) from ${inputs.length} declared input(s)`;
121
- const empty = inputs.filter((input) => input.files.length === 0);
122
- if (violations.length === 0 && empty.length === 0) return `${NAME}: no violation in ${scanned}`;
123
- return [
124
- `${NAME}: ${violations.length + empty.length} problem(s) in ${scanned}:`,
125
- ...empty.map(({ glob }) => ` ${glob}: the declared input matches no file`),
126
- ...violations.map(({ file, line, column, text }) => ` ${file}:${line}:${column} ${text}`),
127
- ].join("\n");
91
+ const empty = emptyInputs(inputs).map(({ glob }) => `${glob}: the declared input matches no file`);
92
+ return inputsReport(DECLARED.name, inputs, [...empty, ...violations.map(violationLine)]);
128
93
  }
129
94
 
130
- const gate = Effect.gen(function* () {
131
- const root = (yield* Path.Path).resolve(process.argv[2] ?? ".");
132
- const { inputs } = yield* readDeclaration(root);
133
- const scan = yield* scanInputs(root, inputs);
134
- yield* Console.log(report(scan));
135
- return passes(scan);
136
- });
137
-
138
- if (import.meta.main) runMain(NAME, gate);
95
+ if (import.meta.main) runDeclaredGate(DECLARED, scanInputs, { report, passes });
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, Schema } from "effect";
3
+ import { workingTreeFiles } from "../core/git.ts";
4
+ import { runMain } from "../core/main.ts";
5
+
6
+ export type Violation = {
7
+ readonly file: string;
8
+ readonly line: number | undefined;
9
+ readonly message: string;
10
+ };
11
+
12
+ const NAME = "no-plain-js";
13
+
14
+ const PLAIN_SOURCE = /\.m?js$/;
15
+
16
+ const UNJUDGED_PREFIXES = ["dist/", "node_modules/", "repos/", "public/", "tests/fixtures/"] as const;
17
+
18
+ const GENERATED_CONFIG = "dependency-cruiser.config.js";
19
+
20
+ const SHIPPED_PRESET = "stryker.preset.js";
21
+
22
+ const STRYKER_CONFIG = /^\.?stryker\.(?:conf|config)\.(?:js|mjs|cjs)$/;
23
+
24
+ function baseNameOf(file: string): string {
25
+ return file.slice(file.lastIndexOf("/") + 1);
26
+ }
27
+
28
+ export function isExempt(file: string): boolean {
29
+ if (UNJUDGED_PREFIXES.some((prefix) => file.startsWith(prefix))) return true;
30
+ if (file === GENERATED_CONFIG) return true;
31
+ const base = baseNameOf(file);
32
+ return base === SHIPPED_PRESET || STRYKER_CONFIG.test(base);
33
+ }
34
+
35
+ export function violationsOf(files: readonly string[]): readonly Violation[] {
36
+ return files
37
+ .filter((file) => PLAIN_SOURCE.test(file) && !isExempt(file))
38
+ .toSorted()
39
+ .map((file) => ({
40
+ file,
41
+ line: undefined,
42
+ message: "plain JavaScript source in a TypeScript repository; convert it to TypeScript",
43
+ }));
44
+ }
45
+
46
+ class NoPlainJsError extends Schema.TaggedError<NoPlainJsError>()("NoPlainJsError", {
47
+ message: Schema.String,
48
+ }) {}
49
+
50
+ export type Result = { readonly files: number; readonly violations: readonly Violation[] };
51
+
52
+ export const run = Effect.fn("run")(function* (root: string) {
53
+ const files = yield* workingTreeFiles(root).pipe(
54
+ Effect.mapError((cause) => new NoPlainJsError({ message: cause.message })),
55
+ );
56
+ return { files: files.length, violations: violationsOf(files) } satisfies Result;
57
+ });
58
+
59
+ export function report({ files, violations }: Result): string {
60
+ if (violations.length === 0) return `${NAME}: no plain JavaScript sources among ${files} tracked file(s)`;
61
+ return [`${NAME}: ${violations.length} violation(s)`, ...violations.map(({ file, message }) => ` ${file}: ${message}`)].join("\n");
62
+ }
63
+
64
+ const noPlainJs = Effect.gen(function* () {
65
+ const root = process.argv[2] ?? process.cwd();
66
+ const result = yield* run(root);
67
+ yield* Console.log(report(result));
68
+ return result.violations.length === 0;
69
+ });
70
+
71
+ if (import.meta.main) runMain(NAME, noPlainJs);
@@ -0,0 +1,4 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ const log = process.env["CHECKS_FLAKE_WORKER_LOG"];
4
+ if (log !== undefined) appendFileSync(log, `${JSON.stringify({ worker: process.pid, file: Bun.main })}\n`);