@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.
- package/CHANGELOG.md +20 -0
- package/README.md +9 -4
- package/{commitlint.config.js → commitlint.config.ts} +1 -1
- package/dist/mutation/mutation-guard-plugin.js +40 -0
- package/dist/mutation/mutation-refusal.js +23 -0
- package/dist/presets/stylelint.d.ts +10 -0
- package/dist/presets/stylelint.js +73 -0
- package/docs/configs/commit-messages.md +2 -2
- package/docs/configs/native-settings.md +15 -2
- package/docs/design.md +26 -1
- package/docs/gates/checks-ci-wiring.md +3 -2
- package/docs/gates/checks-comment-gate.md +2 -0
- package/docs/gates/checks-copy.md +113 -0
- package/docs/gates/checks-docs-only.md +3 -2
- package/docs/gates/checks-docs.md +3 -0
- package/docs/gates/checks-flake.md +49 -1
- package/docs/gates/checks-frontend-syntax.md +87 -12
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-baseline.md +4 -2
- package/docs/gates/checks-mutation-compare.md +33 -8
- package/docs/gates/checks-mutation-scope.md +140 -0
- package/docs/gates/checks-mutation.md +11 -7
- package/docs/gates/checks-no-plain-js.md +60 -0
- package/docs/gates/checks-repetition.md +3 -0
- package/docs/gates/checks-suppressions-ratchet.md +11 -9
- package/docs/gates/checks-test.md +5 -2
- package/package.json +33 -6
- package/src/complexity/suppressions-ratchet.ts +37 -25
- package/src/core/declared-inputs.ts +77 -0
- package/src/core/gates.ts +4 -0
- package/src/core/git.ts +9 -0
- package/src/delivery/ci-wiring.ts +25 -11
- package/src/delivery/commit-title.ts +2 -2
- package/src/docs/copy-html.ts +110 -0
- package/src/docs/copy-text.ts +70 -0
- package/src/docs/copy.ts +70 -0
- package/src/docs/prose-matchers.ts +14 -14
- package/src/quality/comment-matchers.ts +4 -1
- package/src/quality/frontend-syntax.ts +114 -71
- package/src/quality/no-plain-js.ts +71 -0
- package/src/quality/presets/stylelint.ts +91 -0
- package/src/testing/flake-preload.ts +4 -0
- package/src/testing/flake.ts +142 -16
- package/src/testing/{mutation-scope.js → mutation-refusal.ts} +11 -9
- package/src/testing/mutation-scope.ts +354 -0
- package/src/testing/mutation.ts +1 -1
- package/src/testing/test-layout.ts +4 -20
- package/src/testing/test-skips.ts +1 -1
- package/src/testing/test.ts +19 -3
- package/stryker.preset.js +3 -3
- package/src/testing/mutation-guard-plugin.js +0 -13
package/src/docs/copy.ts
ADDED
|
@@ -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
|
-
|
|
273
|
-
|
|
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(({
|
|
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
|
|
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 {
|
|
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
|
|
8
|
+
const DECLARED: Declared = { name: "frontend-syntax", file: "frontend-syntax.json", declares: "inputs" };
|
|
7
9
|
|
|
8
|
-
const
|
|
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
|
-
|
|
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
|
|
26
|
+
const JUDGES_ONLY_WHAT_PARSES = "The gate judges only what parses.";
|
|
28
27
|
|
|
29
|
-
const
|
|
28
|
+
const UNPARSED_ADVICE: Advice = { [UNPARSED]: JUDGES_ONLY_WHAT_PARSES, [UNPARSED_PART]: JUDGES_ONLY_WHAT_PARSES };
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 ${
|
|
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
|
|
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,
|
|
68
|
+
const file = (yield* Path.Path).join(root, STYLELINT_CONFIG);
|
|
68
69
|
if (!(yield* fs.exists(file))) {
|
|
69
|
-
return yield* new Usage({
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
column,
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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({
|
|
97
|
-
catch: (cause) => (isNoFilesFound(cause) ? new NoFilesFound() : new FrontendSyntaxError({ message: `stylelint fails on ${
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
158
|
+
return violations.length === 0 && emptyInputs(inputs).length === 0;
|
|
116
159
|
}
|
|
117
160
|
|
|
118
161
|
export function report({ inputs, violations }: Scan): string {
|
|
119
|
-
const
|
|
120
|
-
|
|
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
|
|
132
|
-
|
|
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(
|
|
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
|
+
}
|