@avi2dg/checks 0.43.0 → 0.45.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 (51) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +9 -4
  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/dist/presets/stylelint.d.ts +10 -0
  7. package/dist/presets/stylelint.js +73 -0
  8. package/docs/configs/commit-messages.md +2 -2
  9. package/docs/configs/native-settings.md +15 -2
  10. package/docs/design.md +26 -1
  11. package/docs/gates/checks-ci-wiring.md +3 -2
  12. package/docs/gates/checks-comment-gate.md +2 -0
  13. package/docs/gates/checks-copy.md +113 -0
  14. package/docs/gates/checks-docs-only.md +3 -2
  15. package/docs/gates/checks-docs.md +3 -0
  16. package/docs/gates/checks-flake.md +49 -1
  17. package/docs/gates/checks-frontend-syntax.md +87 -12
  18. package/docs/gates/checks-lint.md +2 -1
  19. package/docs/gates/checks-mutation-baseline.md +4 -2
  20. package/docs/gates/checks-mutation-compare.md +33 -8
  21. package/docs/gates/checks-mutation-scope.md +140 -0
  22. package/docs/gates/checks-mutation.md +11 -7
  23. package/docs/gates/checks-no-plain-js.md +60 -0
  24. package/docs/gates/checks-repetition.md +3 -0
  25. package/docs/gates/checks-suppressions-ratchet.md +11 -9
  26. package/docs/gates/checks-test.md +5 -2
  27. package/package.json +33 -6
  28. package/src/complexity/suppressions-ratchet.ts +37 -25
  29. package/src/core/declared-inputs.ts +77 -0
  30. package/src/core/gates.ts +4 -0
  31. package/src/core/git.ts +9 -0
  32. package/src/delivery/ci-wiring.ts +25 -11
  33. package/src/delivery/commit-title.ts +2 -2
  34. package/src/docs/copy-html.ts +110 -0
  35. package/src/docs/copy-text.ts +70 -0
  36. package/src/docs/copy.ts +70 -0
  37. package/src/docs/prose-matchers.ts +14 -14
  38. package/src/quality/comment-matchers.ts +4 -1
  39. package/src/quality/frontend-syntax.ts +114 -71
  40. package/src/quality/no-plain-js.ts +71 -0
  41. package/src/quality/presets/stylelint.ts +91 -0
  42. package/src/testing/flake-preload.ts +4 -0
  43. package/src/testing/flake.ts +142 -16
  44. package/src/testing/{mutation-scope.js → mutation-refusal.ts} +11 -9
  45. package/src/testing/mutation-scope.ts +354 -0
  46. package/src/testing/mutation.ts +1 -1
  47. package/src/testing/test-layout.ts +4 -20
  48. package/src/testing/test-skips.ts +1 -1
  49. package/src/testing/test.ts +19 -3
  50. package/stryker.preset.js +3 -3
  51. package/src/testing/mutation-guard-plugin.js +0 -13
@@ -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
  }
@@ -40,6 +40,7 @@ const SQL: Syntax = { line: DASHES, block: SLASHES, quotes: STRINGS, regexLitera
40
40
  const LUA: Syntax = { line: DASHES, block: [["--[[", "]]"]], quotes: STRINGS, regexLiterals: false };
41
41
  const HASKELL: Syntax = { line: DASHES, block: [["{-", "-}"]], quotes: [escaping('"')], regexLiterals: false };
42
42
  const ML: Syntax = { line: [], block: [["(*", "*)"]], quotes: [escaping('"')], regexLiterals: false };
43
+ const CSS: Syntax = { line: [], block: SLASHES, quotes: STRINGS, regexLiterals: false };
43
44
 
44
45
  export const SYNTAXES: Readonly<Record<string, Syntax>> = {
45
46
  ts: JS,
@@ -78,6 +79,7 @@ export const SYNTAXES: Readonly<Record<string, Syntax>> = {
78
79
  yml: YAML,
79
80
  toml: HASH,
80
81
  ml: ML,
82
+ css: CSS,
81
83
  };
82
84
 
83
85
  const extensionOf = (path: string): string => path.slice(path.lastIndexOf(".") + 1);
@@ -247,13 +249,14 @@ export const REFUSED_DIRECTIVES = [
247
249
  "biome-ignore",
248
250
  "eslint-disable",
249
251
  "prettier-ignore",
252
+ "stylelint-disable",
250
253
  ] as const satisfies readonly string[];
251
254
 
252
255
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
253
256
 
254
257
  const directiveSource = (name: string): string => {
255
258
  const escaped = escapeRegExp(name);
256
- if (name === "eslint-disable") return `\\b${escaped}[\\w-]*`;
259
+ if (name.endsWith("-disable")) return `\\b${escaped}[\\w-]*`;
257
260
  return /^[\w]/.test(name) ? `\\b${escaped}\\b` : escaped;
258
261
  };
259
262
 
@@ -1,43 +1,36 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import type { LintResult } from "stylelint";
2
+ import { Effect, FileSystem, Path, Schema } from "effect";
3
+ import type { Config, LinterOptions, LintResult } from "stylelint";
4
+ import { declaredGate, emptyInputs, inputsReport, violationLine, type Declared, type Input, type Violation } from "../core/declared-inputs.ts";
4
5
  import { runMain, Usage } from "../core/main.ts";
6
+ import { MARKUP } from "./presets/stylelint.ts";
5
7
 
6
- const NAME = "frontend-syntax";
8
+ const DECLARED: Declared = { name: "frontend-syntax", file: "frontend-syntax.json", declares: "inputs" };
7
9
 
8
- const DECLARATION = "frontend-syntax.json";
10
+ const STYLELINT_CONFIG = "stylelint.config.ts";
11
+
12
+ const USAGE = "usage: frontend-syntax.ts [--fix] [--suppress] [<directory>]";
9
13
 
10
14
  const TRANSITION_ALL = "declaration-property-value-disallowed-list";
11
15
  const BODY_WIDE_USER_SELECT = "rule-selector-property-disallowed-list";
12
16
  const UNPARSED = "CssSyntaxError";
17
+ const UNPARSED_PART = "parseError";
13
18
 
14
19
  const RULES = {
15
20
  [TRANSITION_ALL]: [{ "/^(?:-[a-z]+-)?transition(?:-property)?$/": ["/(?:^|[\\s,])all(?:[\\s,]|$)/i"] }],
16
21
  [BODY_WIDE_USER_SELECT]: [{ "/(?:^|,)\\s*(?:html|body|:root|\\*|:global\\(\\s*(?:html|body|:root|\\*)\\s*\\))\\s*(?:,|$)/": ["/^(?:-[a-z]+-)?user-select$/"] }],
17
22
  } as const;
18
23
 
19
- const ADVICE: Readonly<Record<string, string>> = {
20
- [TRANSITION_ALL]: "Name the properties the transition animates.",
21
- [BODY_WIDE_USER_SELECT]: "Leave text selectable across the page, and turn selection off only on the control that needs it.",
22
- [UNPARSED]: "The gate judges only what parses.",
23
- };
24
-
25
- const MARKUP = ["**/*.html", "**/*.astro", "**/*.vue", "**/*.svelte"];
24
+ type Advice = Readonly<Record<string, string>>;
26
25
 
27
- const Declaration = Schema.Struct({ inputs: Schema.NonEmptyArray(Schema.NonEmptyString) });
26
+ const JUDGES_ONLY_WHAT_PARSES = "The gate judges only what parses.";
28
27
 
29
- const decodeDeclaration = Schema.decodeUnknownEffect(Schema.fromJsonString(Declaration));
28
+ const UNPARSED_ADVICE: Advice = { [UNPARSED]: JUDGES_ONLY_WHAT_PARSES, [UNPARSED_PART]: JUDGES_ONLY_WHAT_PARSES };
30
29
 
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[];
30
+ const KIT_ADVICE: Advice = {
31
+ [TRANSITION_ALL]: "Name the properties the transition animates.",
32
+ [BODY_WIDE_USER_SELECT]: "Leave text selectable across the page, and turn selection off only on the control that needs it.",
33
+ ...UNPARSED_ADVICE,
41
34
  };
42
35
 
43
36
  export type Scan = {
@@ -45,6 +38,10 @@ export type Scan = {
45
38
  readonly violations: readonly Violation[];
46
39
  };
47
40
 
41
+ type Options = { readonly fix: boolean; readonly suppress: boolean };
42
+
43
+ type Pass = { readonly config: Config; readonly advice: Advice; readonly refuses: string };
44
+
48
45
  class FrontendSyntaxError extends Schema.TaggedError<FrontendSyntaxError>()("FrontendSyntaxError", {
49
46
  message: Schema.String,
50
47
  }) {}
@@ -59,80 +56,126 @@ const loadPeer = <A>(name: string, load: () => Promise<A>) =>
59
56
  Effect.tryPromise({
60
57
  try: load,
61
58
  catch: () =>
62
- new Usage({ message: `cannot load ${name}, which a repository that declares ${DECLARATION} installs beside the kit as an optional peer` }),
59
+ new Usage({ message: `cannot load ${name}, which a repository that declares ${DECLARED.file} installs beside the kit as an optional peer` }),
63
60
  });
64
61
 
65
- const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
62
+ const ConfigModule = Schema.Struct({ default: Schema.Record(Schema.String, Schema.Unknown) });
63
+
64
+ const decodeConfigModule = Schema.decodeUnknownEffect(ConfigModule);
65
+
66
+ const loadRepositoryConfig = Effect.fn("loadRepositoryConfig")(function* (root: string) {
66
67
  const fs = yield* FileSystem.FileSystem;
67
- const file = (yield* Path.Path).join(root, DECLARATION);
68
+ const file = (yield* Path.Path).join(root, STYLELINT_CONFIG);
68
69
  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
+ return yield* new Usage({
71
+ message: `${STYLELINT_CONFIG} is missing, and a repository that declares ${DECLARED.file} writes it with defineConfig from @avi2dg/checks/stylelint`,
72
+ });
70
73
  }
71
- return yield* decodeDeclaration(yield* fs.readFileString(file)).pipe(
72
- Effect.mapError(({ message }) => new Usage({ message: `${DECLARATION} does not decode: ${message}` })),
74
+ const loaded: unknown = yield* Effect.tryPromise({
75
+ try: () => import(file),
76
+ catch: (cause) => new Usage({ message: `${STYLELINT_CONFIG} does not load: ${String(cause)}` }),
77
+ });
78
+ const module = yield* decodeConfigModule(loaded).pipe(
79
+ Effect.mapError(() => new Usage({ message: `${STYLELINT_CONFIG} has no default export of a stylelint config object` })),
73
80
  );
81
+ return module.default;
74
82
  });
75
83
 
76
- function violationsOf(result: LintResult, relative: (file: string) => string): readonly Violation[] {
84
+ function violationsOf(result: LintResult, relative: (file: string) => string, advice: Advice): readonly Violation[] {
77
85
  const file = relative(result.source ?? "");
78
- return result.warnings.map(({ line, column, rule, text }) => ({
79
- file,
80
- line,
81
- column,
82
- text: `${text.replace(` (${rule})`, "")}. ${ADVICE[rule] ?? ""}`.trim(),
83
- }));
86
+ const unparsed = result.parseErrors.map(({ line, column, stylelintType, text }) => ({ line, column, rule: stylelintType, text }));
87
+ return [...result.warnings, ...unparsed].map(({ line, column, rule, text }) => {
88
+ const advised = advice[rule];
89
+ return { file, line, column, text: advised === undefined ? text : `${text.replace(` (${rule})`, "")}. ${advised}` };
90
+ });
84
91
  }
85
92
 
86
- export const scanInputs = Effect.fn("scanInputs")(function* (root: string, globs: readonly string[]) {
87
- const path = yield* Path.Path;
88
- const stylelint = (yield* loadPeer("stylelint", () => import("stylelint"))).default;
89
- const html = (yield* loadPeer("postcss-html", () => import("postcss-html"))).default;
90
- const config = { rules: RULES, overrides: [{ files: MARKUP, customSyntax: html }] };
91
- const relative = (file: string): string => path.relative(root, file);
92
- const inputs: Input[] = [];
93
- const violations = new Map<string, Violation>();
94
- for (const glob of globs) {
93
+ const lintWith = (root: string, pass: Pass, options: Partial<LinterOptions>) =>
94
+ Effect.gen(function* () {
95
+ const stylelint = (yield* loadPeer("stylelint", () => import("stylelint"))).default;
96
+ const files = options.files ?? [];
95
97
  const linted: readonly LintResult[] = yield* Effect.tryPromise({
96
- try: () => stylelint.lint({ files: glob, cwd: root, config }),
97
- catch: (cause) => (isNoFilesFound(cause) ? new NoFilesFound() : new FrontendSyntaxError({ message: `stylelint fails on ${glob}: ${String(cause)}` })),
98
+ try: () => stylelint.lint({ ...options, cwd: root, configBasedir: root, config: pass.config, ignoreDisables: true }),
99
+ catch: (cause) => (isNoFilesFound(cause) ? new NoFilesFound() : new FrontendSyntaxError({ message: `stylelint fails on ${String(files)}: ${String(cause)}` })),
98
100
  }).pipe(
99
101
  Effect.map(({ results }) => results),
100
102
  Effect.catchTag("NoFilesFound", () => Effect.succeed([])),
101
103
  );
102
- const results = linted.filter(({ ignored }) => ignored !== true);
103
- const invalid = results.flatMap(({ invalidOptionWarnings }) => invalidOptionWarnings.map(({ text }) => text));
104
- if (invalid.length > 0) return yield* new FrontendSyntaxError({ message: `stylelint refuses the kit's rule options: ${invalid.join("; ")}` });
105
- inputs.push({ glob, files: results.map((result) => relative(result.source ?? "")).toSorted() });
106
- for (const violation of results.flatMap((result) => violationsOf(result, relative))) {
107
- violations.set(`${violation.file}:${violation.line}:${violation.column}:${violation.text}`, violation);
108
- }
104
+ const invalid = linted.flatMap(({ invalidOptionWarnings }) => invalidOptionWarnings.map(({ text }) => text));
105
+ if (invalid.length > 0) return yield* new FrontendSyntaxError({ message: `stylelint refuses ${pass.refuses}: ${[...new Set(invalid)].join("; ")}` });
106
+ return linted;
107
+ });
108
+
109
+ // stylelint applies stylelint-suppressions.json to every file it reads itself, and to no code it is handed.
110
+ const lintUnsuppressed = (root: string, pass: Pass, files: readonly string[]) =>
111
+ Effect.forEach(files, (file) =>
112
+ Effect.gen(function* () {
113
+ const code = yield* (yield* FileSystem.FileSystem).readFileString(file).pipe(
114
+ Effect.mapError((cause) => new FrontendSyntaxError({ message: `cannot read ${file}: ${String(cause)}` })),
115
+ );
116
+ return yield* lintWith(root, pass, { code, codeFilename: file });
117
+ }),
118
+ ).pipe(Effect.map((results) => results.flat()));
119
+
120
+ function isJudged({ ignored }: LintResult): boolean {
121
+ return ignored !== true;
122
+ }
123
+
124
+ const passesFor = Effect.fn("passesFor")(function* (root: string) {
125
+ const html = (yield* loadPeer("postcss-html", () => import("postcss-html"))).default;
126
+ const kit: Pass = { config: { rules: RULES, overrides: [{ files: [...MARKUP], customSyntax: html }] }, advice: KIT_ADVICE, refuses: "the kit's rule options" };
127
+ const repository: Pass = {
128
+ config: { ...(yield* loadRepositoryConfig(root)), quiet: false },
129
+ advice: UNPARSED_ADVICE,
130
+ refuses: `an option in ${STYLELINT_CONFIG}`,
131
+ };
132
+ return { kit, repository };
133
+ });
134
+
135
+ export const scanInputs = Effect.fn("scanInputs")(function* (root: string, globs: readonly string[], { fix, suppress }: Options) {
136
+ const path = yield* Path.Path;
137
+ // stylelint keys its suppressions file on the process directory rather than on its cwd option.
138
+ yield* Effect.sync(() => process.chdir(root));
139
+ const { kit, repository } = yield* passesFor(root);
140
+ if (suppress) yield* lintWith(root, repository, { files: [...globs], fix, suppressAll: true, allowEmptyInput: true });
141
+ const relative = (file: string): string => path.relative(root, file);
142
+ const inputs: Input[] = [];
143
+ const violations = new Map<string, Violation>();
144
+ for (const glob of globs) {
145
+ const matched = yield* lintWith(root, repository, { files: glob, fix });
146
+ const judged = matched.filter(isJudged);
147
+ const ruled = yield* lintUnsuppressed(root, kit, matched.flatMap(({ source }) => (source === undefined ? [] : [source])));
148
+ const files = new Set([...judged, ...ruled.filter(isJudged)].map((result) => relative(result.source ?? "")));
149
+ inputs.push({ glob, files: [...files].toSorted() });
150
+ const found = [...ruled.flatMap((result) => violationsOf(result, relative, kit.advice)), ...judged.flatMap((result) => violationsOf(result, relative, repository.advice))];
151
+ for (const violation of found) violations.set(`${violation.file}:${violation.line}:${violation.column}:${violation.text}`, violation);
109
152
  }
110
- const byPlace = (a: Violation, b: Violation): number => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column;
153
+ const byPlace = (a: Violation, b: Violation): number => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || a.text.localeCompare(b.text);
111
154
  return { inputs, violations: [...violations.values()].toSorted(byPlace) } satisfies Scan;
112
155
  });
113
156
 
114
157
  export function passes({ inputs, violations }: Scan): boolean {
115
- return violations.length === 0 && inputs.every(({ files }) => files.length > 0);
158
+ return violations.length === 0 && emptyInputs(inputs).length === 0;
116
159
  }
117
160
 
118
161
  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");
162
+ const empty = emptyInputs(inputs).map(({ glob }) => `${glob}: the declared input matches no file`);
163
+ return inputsReport(DECLARED.name, inputs, [...empty, ...violations.map(violationLine)]);
128
164
  }
129
165
 
166
+ type Arguments = Options & { readonly directory: string | undefined };
167
+
168
+ export const parseArguments = Effect.fn("parseArguments")(function* (args: readonly string[]) {
169
+ const flags = new Set(args.filter((arg) => arg.startsWith("--")));
170
+ const directories = args.filter((arg) => !arg.startsWith("--"));
171
+ const unknown = [...flags].filter((flag) => flag !== "--fix" && flag !== "--suppress");
172
+ if (unknown.length > 0 || directories.length > 1) return yield* new Usage({ message: USAGE });
173
+ return { fix: flags.has("--fix"), suppress: flags.has("--suppress"), directory: directories[0] } satisfies Arguments;
174
+ });
175
+
130
176
  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);
177
+ const { directory, ...options } = yield* parseArguments(process.argv.slice(2));
178
+ return yield* declaredGate(DECLARED, (root, globs) => scanInputs(root, globs, options), { report, passes }, directory);
136
179
  });
137
180
 
138
- if (import.meta.main) runMain(NAME, gate);
181
+ if (import.meta.main) runMain(DECLARED.name, gate);
@@ -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,91 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { Config } from "stylelint";
3
+
4
+ type Globs = readonly [string, ...string[]];
5
+
6
+ export type BaselineTarget = "widely" | "newly" | number;
7
+
8
+ export type ChecksStylelintConfig = Omit<Config, "extends" | "plugins"> & {
9
+ readonly baseline: BaselineTarget;
10
+ readonly tokens: Globs | false;
11
+ };
12
+
13
+ type Rules = NonNullable<Config["rules"]>;
14
+
15
+ const HTML_AND_ASTRO = ["**/*.html", "**/*.astro"] as const;
16
+
17
+ export const MARKUP = [...HTML_AND_ASTRO, "**/*.vue", "**/*.svelte"] as const;
18
+
19
+ // stylelint resolves a name from the repository, and the kit's peers sit beside the kit rather than under every repository it lints.
20
+ function peer(name: string): string {
21
+ return fileURLToPath(import.meta.resolve(name));
22
+ }
23
+
24
+ const CONVENTION_CHANGES = {
25
+ "no-descending-specificity": null,
26
+ "property-no-vendor-prefix": [true, { disableFix: true, ignoreProperties: ["-webkit-user-select"] }],
27
+ } as const satisfies Rules;
28
+
29
+ const SELECTOR_COMPLEXITY = {
30
+ "selector-max-specificity": "0,3,2",
31
+ "selector-max-compound-selectors": 3,
32
+ } as const satisfies Rules;
33
+
34
+ const COLOUR_FUNCTIONS = ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color"] as const;
35
+
36
+ const DESIGN_TOKENS = {
37
+ "color-no-hex": true,
38
+ "color-named": "never",
39
+ "function-disallowed-list": COLOUR_FUNCTIONS.map((name) => `/^${name}$/i`),
40
+ "declaration-property-value-allowed-list": { "/^font-size$/i": ["/^var\\(\\s*--[^\\s,()]+\\s*\\)$/i", "/^inherit$/i"] },
41
+ } as const satisfies Rules;
42
+
43
+ const REFUSED_BASELINE = '@avi2dg/checks/stylelint: set baseline to "widely", "newly" or a year such as 2024';
44
+
45
+ const REFUSED_TOKENS = "@avi2dg/checks/stylelint: set tokens to the globs of the token files, or false to leave the design token rules off";
46
+
47
+ function baselineTarget(baseline: unknown): BaselineTarget {
48
+ if (baseline === "widely" || baseline === "newly") return baseline;
49
+ if (typeof baseline === "number" && Number.isInteger(baseline)) return baseline;
50
+ throw new TypeError(REFUSED_BASELINE);
51
+ }
52
+
53
+ function isGlobList(value: unknown): value is readonly string[] {
54
+ return Array.isArray(value) && value.every((glob) => typeof glob === "string" && glob !== "");
55
+ }
56
+
57
+ function tokenFiles(tokens: unknown): readonly string[] {
58
+ if (tokens === false) return [];
59
+ if (isGlobList(tokens) && tokens.length > 0) return tokens;
60
+ throw new TypeError(REFUSED_TOKENS);
61
+ }
62
+
63
+ function designTokens(files: readonly string[]): Required<Pick<Config, "rules" | "overrides">> {
64
+ if (files.length === 0) return { rules: {}, overrides: [] };
65
+ const off = Object.fromEntries(Object.keys(DESIGN_TOKENS).map((rule) => [rule, null]));
66
+ return { rules: DESIGN_TOKENS, overrides: [{ files: [...files], rules: off }] };
67
+ }
68
+
69
+ // The types refuse a missing or malformed baseline or tokens, and the guards repeat it for a config no typecheck reads.
70
+ export function defineConfig({ baseline, tokens, rules, overrides = [], ...rest }: ChecksStylelintConfig): Config {
71
+ const available = baselineTarget(baseline);
72
+ const tokenRules = designTokens(tokenFiles(tokens));
73
+ return {
74
+ ...rest,
75
+ extends: [peer("stylelint-config-standard")],
76
+ plugins: [peer("stylelint-plugin-use-baseline")],
77
+ rules: {
78
+ ...CONVENTION_CHANGES,
79
+ ...SELECTOR_COMPLEXITY,
80
+ "plugin/use-baseline": [true, { available }],
81
+ ...tokenRules.rules,
82
+ ...rules,
83
+ },
84
+ overrides: [
85
+ { files: [...MARKUP], customSyntax: peer("postcss-html") },
86
+ { files: [...HTML_AND_ASTRO], rules: { "declaration-block-single-line-max-declarations": null } },
87
+ ...tokenRules.overrides,
88
+ ...overrides,
89
+ ],
90
+ };
91
+ }
@@ -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`);