@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.
- package/CHANGELOG.md +20 -0
- package/README.md +5 -2
- 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/docs/configs/commit-messages.md +2 -2
- package/docs/design.md +20 -0
- package/docs/gates/checks-ci-wiring.md +3 -2
- 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-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-test-layout.md +3 -2
- package/docs/gates/checks-test.md +35 -6
- package/package.json +18 -6
- package/src/core/declared-inputs.ts +76 -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/frontend-syntax.ts +9 -52
- package/src/quality/no-plain-js.ts +71 -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 +8 -15
- package/src/testing/test-report.ts +20 -5
- package/src/testing/test-skips.ts +1 -1
- package/src/testing/test.ts +53 -11
- package/stryker.preset.js +3 -3
- 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
|
+
}
|
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
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { Effect, Path, Schema } from "effect";
|
|
3
3
|
import type { LintResult } from "stylelint";
|
|
4
|
-
import {
|
|
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
|
|
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 ${
|
|
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
|
|
87
|
+
return violations.length === 0 && emptyInputs(inputs).length === 0;
|
|
116
88
|
}
|
|
117
89
|
|
|
118
90
|
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");
|
|
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
|
-
|
|
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);
|