@avi2dg/checks 0.21.0 → 0.23.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 +66 -40
- package/CONTRIBUTING.md +14 -10
- package/README.md +15 -24
- package/bunfig.toml +1 -1
- package/dist/effect-channel/index.js +122 -0
- package/dist/{index.js → readability/index.js} +8 -120
- package/docs/configs/commit-messages.md +5 -1
- package/docs/configs/dependency-rules.md +5 -2
- package/docs/configs/effect-rules.md +32 -33
- package/docs/configs/native-settings.md +74 -0
- package/docs/configs/typescript-rules.md +4 -0
- package/docs/design.md +26 -27
- package/docs/gates/checks-backtest.md +4 -0
- package/docs/gates/checks-ci-wiring.md +30 -93
- package/docs/gates/checks-comment-gate.md +4 -0
- package/docs/gates/checks-commit-identity.md +23 -27
- package/docs/gates/checks-docs.md +23 -21
- package/docs/gates/checks-flake.md +4 -10
- package/docs/gates/checks-lint-coverage.md +5 -1
- package/docs/gates/checks-lint.md +22 -104
- package/docs/gates/checks-mutation-compare.md +4 -0
- package/docs/gates/checks-quarantine-clock.md +4 -0
- package/docs/gates/checks-repetition.md +27 -41
- package/docs/gates/checks-subsumed-tests.md +4 -0
- package/docs/gates/checks-suppressions-ratchet.md +4 -0
- package/docs/gates/checks-test-layout.md +16 -8
- package/docs/gates/checks-test.md +68 -36
- package/docs/gates/checks-vendor.md +20 -13
- package/oxlintrc.json +1 -1
- package/package.json +10 -21
- package/scripts/ci-wiring.ts +28 -97
- package/scripts/commit-identity.ts +32 -4
- package/scripts/doc-rules.ts +26 -11
- package/scripts/doc-templates.ts +2 -1
- package/scripts/docs.ts +4 -7
- package/scripts/gates.ts +0 -29
- package/scripts/git.ts +24 -1
- package/scripts/lint.ts +15 -34
- package/scripts/range-gate.ts +1 -2
- package/scripts/repetition.ts +51 -40
- package/scripts/shell-command.ts +7 -1
- package/scripts/swc.ts +46 -0
- package/scripts/test-layout.ts +40 -56
- package/scripts/test-skips.ts +180 -0
- package/scripts/test.ts +33 -38
- package/scripts/vendor.ts +55 -11
- package/dist/feature-rules.js +0 -354
- package/docs/configs/quality-file.md +0 -103
- package/docs/gates/checks-feature-owners.md +0 -113
- package/docs/gates/checks-quality.md +0 -111
- package/docs/gates/checks-size-budget.md +0 -107
- package/quality.schema.json +0 -514
- package/scripts/feature-owners.ts +0 -139
- package/scripts/quality-file.ts +0 -353
- package/scripts/quality.ts +0 -363
- package/scripts/size-budget.ts +0 -285
- package/scripts/size-rules.ts +0 -126
package/scripts/repetition.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { changedPaths, checkoutFiles, collect, git, pathsAt, rangeFromArgs } from "./git.ts";
|
|
4
4
|
import { runMain } from "./main.ts";
|
|
5
|
-
import { readQuality } from "./quality-file.ts";
|
|
6
5
|
|
|
7
6
|
export type Fragment = {
|
|
8
7
|
readonly file: string;
|
|
@@ -34,19 +33,29 @@ const USAGE = "usage: repetition.ts <ref> | <base-ref> <head-ref>";
|
|
|
34
33
|
const MIN_TOKENS = 50;
|
|
35
34
|
const MIN_LINES = 5;
|
|
36
35
|
const MEASURE = `at ${MIN_TOKENS} tokens and ${MIN_LINES} lines`;
|
|
37
|
-
const
|
|
38
|
-
const TYPESCRIPT = [":(glob)**/*.ts", ":(glob)**/*.tsx", DECLARATIONS];
|
|
36
|
+
const CONFIG = ".jscpd.json";
|
|
39
37
|
const JSCPD_FINISHED = 0;
|
|
40
38
|
const REPORT = "jscpd-report.json";
|
|
41
39
|
|
|
42
40
|
const Location = Schema.Struct({ name: Schema.String, start: Schema.Int, end: Schema.Int });
|
|
43
41
|
|
|
44
42
|
const decodeReport = Schema.decodeUnknownEffect(
|
|
45
|
-
Schema.fromJsonString(
|
|
43
|
+
Schema.fromJsonString(
|
|
44
|
+
Schema.Struct({
|
|
45
|
+
duplicates: Schema.Array(Schema.Struct({ firstFile: Location, secondFile: Location })),
|
|
46
|
+
statistics: Schema.Struct({ total: Schema.Struct({ sources: Schema.Int }) }),
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
46
49
|
);
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
const decodeConfigPaths = Schema.decodeUnknownEffect(
|
|
52
|
+
Schema.fromJsonString(Schema.Struct({ path: Schema.optional(Schema.Array(Schema.String)) })),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
type Scan = { readonly sources: number; readonly clones: readonly Clone[] };
|
|
56
|
+
|
|
57
|
+
function fragmentOf(file: (name: string) => string, { name, start, end }: typeof Location.Type): Fragment {
|
|
58
|
+
return { file: file(name), start, end };
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, number> {
|
|
@@ -60,28 +69,40 @@ export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, num
|
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
// jscpd reads files from disk, and the working tree need not hold the revision: a merge checkout or an uncommitted edit.
|
|
72
|
+
// Both ends scan under the head's config, so a changed file set never reads as a change in repetition.
|
|
73
|
+
// jscpd refuses a missing path, so a path in `emptied` the revision lacks is created empty.
|
|
63
74
|
const scan = Effect.fn("scan")(
|
|
64
|
-
function* (root: string, rev: string,
|
|
65
|
-
|
|
75
|
+
function* (root: string, rev: string, config: string, emptied: readonly string[]) {
|
|
76
|
+
const files = yield* pathsAt(rev, [], root);
|
|
77
|
+
if (files.length === 0) return { sources: 0, clones: [] } satisfies Scan;
|
|
66
78
|
const fs = yield* FileSystem.FileSystem;
|
|
67
79
|
const path = yield* Path.Path;
|
|
68
80
|
const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "checks-repetition-" });
|
|
69
81
|
const tree = yield* checkoutFiles(rev, files, scratch, root);
|
|
82
|
+
yield* fs.writeFileString(path.join(tree, CONFIG), config);
|
|
83
|
+
for (const absent of emptied.map((entry) => path.join(tree, entry))) {
|
|
84
|
+
if (!(yield* fs.exists(absent))) yield* fs.makeDirectory(absent, { recursive: true });
|
|
85
|
+
}
|
|
70
86
|
const output = path.join(scratch, "report");
|
|
71
|
-
const flags = ["--silent", "--no-tips", "--no-gitignore", "--reporters", "json", "--output", output];
|
|
87
|
+
const flags = ["--silent", "--no-tips", "--no-gitignore", "--absolute", "--reporters", "json", "--output", output];
|
|
72
88
|
const thresholds = ["--min-tokens", `${MIN_TOKENS}`, "--min-lines", `${MIN_LINES}`];
|
|
73
89
|
|
|
74
|
-
const run = yield* collect("jscpd", [...flags, ...thresholds
|
|
90
|
+
const run = yield* collect("jscpd", [...flags, ...thresholds], tree).pipe(
|
|
75
91
|
Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot run jscpd: ${cause.message}` })),
|
|
76
92
|
);
|
|
77
|
-
|
|
93
|
+
const reportPath = path.join(output, REPORT);
|
|
94
|
+
// A threshold, exitCode or failOnEmpty in the repository's config fails jscpd after the report is written.
|
|
95
|
+
if (run.exitCode !== JSCPD_FINISHED && !(yield* fs.exists(reportPath))) {
|
|
78
96
|
return yield* new JscpdUnreadable({ message: `jscpd exited ${run.exitCode}: ${run.stderr.trim() || run.stdout.trim()}` });
|
|
79
97
|
}
|
|
80
|
-
const { duplicates } = yield* fs.readFileString(
|
|
98
|
+
const { duplicates, statistics } = yield* fs.readFileString(reportPath).pipe(
|
|
81
99
|
Effect.flatMap(decodeReport),
|
|
82
100
|
Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot read jscpd's report: ${cause.message}` })),
|
|
83
101
|
);
|
|
84
|
-
|
|
102
|
+
const scanned = yield* fs.realPath(tree);
|
|
103
|
+
const file = (name: string): string => path.relative(scanned, name);
|
|
104
|
+
const clones = duplicates.map(({ firstFile, secondFile }): Clone => [fragmentOf(file, firstFile), fragmentOf(file, secondFile)]);
|
|
105
|
+
return { sources: statistics.total.sources, clones } satisfies Scan;
|
|
85
106
|
},
|
|
86
107
|
Effect.scoped,
|
|
87
108
|
);
|
|
@@ -113,33 +134,25 @@ export function risesOf(
|
|
|
113
134
|
.toSorted((a, b) => a.file.localeCompare(b.file));
|
|
114
135
|
}
|
|
115
136
|
|
|
116
|
-
// Tallies every
|
|
117
|
-
export function heldOf(
|
|
118
|
-
measured: number,
|
|
119
|
-
rises: readonly Rise[],
|
|
120
|
-
after: ReadonlyMap<string, number>,
|
|
121
|
-
othersRepeated: ReadonlyMap<string, number>,
|
|
122
|
-
): Held {
|
|
137
|
+
// Tallies every repeating file that did not rise as advisory.
|
|
138
|
+
export function heldOf(measured: number, rises: readonly Rise[], after: ReadonlyMap<string, number>): Held {
|
|
123
139
|
const risen = new Set(rises.map((rise) => rise.file));
|
|
124
|
-
const advisory = [...after].filter(([file]) => !risen.has(file))
|
|
140
|
+
const advisory = [...after].filter(([file]) => !risen.has(file));
|
|
125
141
|
return { measured, rises, advisory: new Map(advisory.toSorted(([a], [b]) => a.localeCompare(b))) };
|
|
126
142
|
}
|
|
127
143
|
|
|
128
|
-
const runHold = Effect.fn("runHold")(function* (root: string,
|
|
129
|
-
const pathspecs = [...production.map((glob) => `:(glob)${glob}`), DECLARATIONS];
|
|
130
|
-
const held = yield* pathsAt(head, pathspecs, root);
|
|
131
|
-
const holds = new Set(held);
|
|
132
|
-
const others = (yield* pathsAt(head, TYPESCRIPT, root)).filter((file) => !holds.has(file));
|
|
144
|
+
const runHold = Effect.fn("runHold")(function* (root: string, config: string, base: string, head: string) {
|
|
133
145
|
const formerPath = new Map(
|
|
134
|
-
(yield* changedPaths(base, head,
|
|
146
|
+
(yield* changedPaths(base, head, [], root)).flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from]] : [])),
|
|
135
147
|
);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
const { path: configPaths = [] } = yield* decodeConfigPaths(config).pipe(
|
|
149
|
+
Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot read ${CONFIG}: ${cause.message}` })),
|
|
150
|
+
);
|
|
151
|
+
const before = repeatedLines((yield* scan(root, base, config, configPaths)).clones);
|
|
152
|
+
const { sources, clones } = yield* scan(root, head, config, []);
|
|
139
153
|
const after = repeatedLines(clones);
|
|
140
|
-
const rises = risesOf(
|
|
141
|
-
|
|
142
|
-
return heldOf(held.length, rises, after, othersRepeated) satisfies Held;
|
|
154
|
+
const rises = risesOf([...after.keys()], before, after, formerPath, clones);
|
|
155
|
+
return heldOf(sources, rises, after) satisfies Held;
|
|
143
156
|
});
|
|
144
157
|
|
|
145
158
|
export function describe({ file, before, after, clones }: Rise): readonly string[] {
|
|
@@ -152,8 +165,8 @@ export function describe({ file, before, after, clones }: Rise): readonly string
|
|
|
152
165
|
export function report({ measured, rises, advisory }: Held): string {
|
|
153
166
|
const verdict =
|
|
154
167
|
rises.length === 0
|
|
155
|
-
? [`${NAME}: ${measured}
|
|
156
|
-
: [`${NAME}: ${rises.length}
|
|
168
|
+
? [`${NAME}: ${measured} file(s) ${CONFIG} holds repeat no more lines than where the range starts, ${MEASURE}`]
|
|
169
|
+
: [`${NAME}: ${rises.length} file(s) ${CONFIG} holds repeat more lines than where the range starts, ${MEASURE}:`, ...rises.flatMap(describe)];
|
|
157
170
|
const notice =
|
|
158
171
|
advisory.size === 0
|
|
159
172
|
? []
|
|
@@ -167,13 +180,11 @@ export function report({ measured, rises, advisory }: Held): string {
|
|
|
167
180
|
const hold = Effect.gen(function* () {
|
|
168
181
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
169
182
|
const { base, head } = yield* rangeFromArgs(process.argv.slice(2), USAGE, root);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (production.length === 0) {
|
|
173
|
-
yield* Console.log(`${NAME}: ${source} declares no sources.production`);
|
|
183
|
+
if ((yield* pathsAt(head, [`:(literal)${CONFIG}`], root)).length === 0) {
|
|
184
|
+
yield* Console.log(`${NAME}: the head holds no ${CONFIG}, so no file is measured`);
|
|
174
185
|
return true;
|
|
175
186
|
}
|
|
176
|
-
const held = yield* runHold(root,
|
|
187
|
+
const held = yield* runHold(root, yield* git(["show", `${head}:${CONFIG}`], root), base, head);
|
|
177
188
|
|
|
178
189
|
yield* Console.log(report(held));
|
|
179
190
|
return held.rises.length === 0;
|
package/scripts/shell-command.ts
CHANGED
|
@@ -64,8 +64,14 @@ export function plainCommand(script: string): Command | undefined {
|
|
|
64
64
|
return words.length === 0 ? undefined : words;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// bun run resolves any other word, even one holding a slash, to a package.json script of that name first.
|
|
68
|
+
const FILE_PATH = /^\.{0,2}\//;
|
|
69
|
+
|
|
67
70
|
export function invokes(command: Command | undefined, gate: Command): boolean {
|
|
68
|
-
|
|
71
|
+
if (command === undefined) return false;
|
|
72
|
+
const begins = (words: Command) => gate.length <= words.length && gate.every((word, index) => words[index] === word);
|
|
73
|
+
const bunRunsFile = command[0] === "bun" && command[1] === "run" && FILE_PATH.test(gate[0] ?? "");
|
|
74
|
+
return begins(command) || (bunRunsFile && begins(command.slice(2)));
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
export function mentions(script: string, gate: Command): boolean {
|
package/scripts/swc.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { parse } from "@swc/core";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
export class TypeScriptParseError extends Schema.TaggedError<TypeScriptParseError>()("TypeScriptParseError", {
|
|
5
|
+
message: Schema.String,
|
|
6
|
+
}) {}
|
|
7
|
+
|
|
8
|
+
export const parseTypeScript = Effect.fn("parseTypeScript")(function* (file: string, source: string) {
|
|
9
|
+
return yield* Effect.tryPromise({
|
|
10
|
+
try: () => parse(source, { syntax: "typescript", tsx: file.endsWith(".tsx"), target: "esnext" }),
|
|
11
|
+
catch: (cause) => new TypeScriptParseError({ message: `cannot parse ${file}: ${String(cause)}` }),
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === "object" && value !== null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function identifierName(node: unknown): string | undefined {
|
|
20
|
+
if (!isRecord(node) || node["type"] !== "Identifier") return undefined;
|
|
21
|
+
const value = node["value"];
|
|
22
|
+
return typeof value === "string" ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stringValue(node: unknown): string | undefined {
|
|
26
|
+
if (!isRecord(node) || node["type"] !== "StringLiteral") return undefined;
|
|
27
|
+
const value = node["value"];
|
|
28
|
+
return typeof value === "string" ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function spanStart(node: Record<string, unknown>): number {
|
|
32
|
+
const span = node["span"];
|
|
33
|
+
if (!isRecord(span)) return 0;
|
|
34
|
+
const start = span["start"];
|
|
35
|
+
return typeof start === "number" ? start : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function lineOf(source: string, start: number): number {
|
|
39
|
+
const bytes = Buffer.from(source, "utf8");
|
|
40
|
+
const offset = Math.max(0, Math.min(bytes.length, start - 1));
|
|
41
|
+
let line = 1;
|
|
42
|
+
for (let index = 0; index < offset; index += 1) {
|
|
43
|
+
if (bytes[index] === 0x0a) line += 1;
|
|
44
|
+
}
|
|
45
|
+
return line;
|
|
46
|
+
}
|
package/scripts/test-layout.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { parse } from "@swc/core";
|
|
3
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
4
3
|
import { git } from "./git.ts";
|
|
5
4
|
import { runMain } from "./main.ts";
|
|
6
|
-
import { ENTRY_POINT,
|
|
7
|
-
import {
|
|
5
|
+
import { ENTRY_POINT, TEST_ENTRY_POINT } from "./gates.ts";
|
|
6
|
+
import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "./swc.ts";
|
|
8
7
|
|
|
9
8
|
export type Violation = {
|
|
10
9
|
readonly file: string;
|
|
@@ -42,12 +41,15 @@ const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
|
|
|
42
41
|
|
|
43
42
|
const IGNORES_KEY = "pathIgnorePatterns";
|
|
44
43
|
const QUARANTINE = "**/tests/quarantine/**";
|
|
44
|
+
const LIVE_TESTS = "**/tests/live/**";
|
|
45
|
+
const PIXEL_TESTS = "**/tests/pixel/**";
|
|
45
46
|
const VENDORED = "repos/**";
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const TEST_TIERS = ["live", "pixel"] as const;
|
|
48
|
+
const OUT_OF_PROCESS: readonly string[] = [E2E, ...TEST_TIERS.map((tier) => `${TESTS}${tier}/`)];
|
|
49
|
+
const ACCEPTED_IGNORES: readonly (readonly string[])[] = [
|
|
50
|
+
[QUARANTINE, LIVE_TESTS, PIXEL_TESTS, VENDORED],
|
|
51
|
+
[QUARANTINE, LIVE_TESTS, PIXEL_TESTS],
|
|
52
|
+
];
|
|
51
53
|
export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
|
|
52
54
|
export const LAYOUT_CHECK_BIN = "checks-test-layout";
|
|
53
55
|
const OWN_ENTRY_POINT = `scripts/${ENTRY_POINT.script}`;
|
|
@@ -57,10 +59,6 @@ export class LayoutError extends Schema.TaggedError<LayoutError>()("LayoutError"
|
|
|
57
59
|
message: Schema.String,
|
|
58
60
|
}) {}
|
|
59
61
|
|
|
60
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
61
|
-
return typeof value === "object" && value !== null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
62
|
function runsLayoutCheck(lint: string): boolean {
|
|
65
63
|
if (lint.includes(LAYOUT_CHECK_MARK) || lint.includes(LAYOUT_CHECK_BIN)) return true;
|
|
66
64
|
return lint.split(/[\s|&;()]+/).some((word) => word === ENTRY_POINT.bin || word === OWN_ENTRY_POINT);
|
|
@@ -104,40 +102,11 @@ export function placementViolations(files: readonly string[]): readonly Violatio
|
|
|
104
102
|
return violations;
|
|
105
103
|
}
|
|
106
104
|
|
|
107
|
-
function lineOf(source: string, start: number): number {
|
|
108
|
-
const bytes = Buffer.from(source, "utf8");
|
|
109
|
-
const offset = Math.max(0, Math.min(bytes.length, start - 1));
|
|
110
|
-
let line = 1;
|
|
111
|
-
for (let index = 0; index < offset; index += 1) {
|
|
112
|
-
if (bytes[index] === 0x0a) line += 1;
|
|
113
|
-
}
|
|
114
|
-
return line;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
105
|
function bannedModule(specifier: string): string | undefined {
|
|
118
106
|
const bare = specifier.startsWith("node:") ? specifier.slice("node:".length) : specifier;
|
|
119
107
|
return BANNED_MODULES.includes(bare) ? bare : undefined;
|
|
120
108
|
}
|
|
121
109
|
|
|
122
|
-
function identifierName(node: unknown): string | undefined {
|
|
123
|
-
if (!isRecord(node) || node["type"] !== "Identifier") return undefined;
|
|
124
|
-
const value = node["value"];
|
|
125
|
-
return typeof value === "string" ? value : undefined;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function stringValue(node: unknown): string | undefined {
|
|
129
|
-
if (!isRecord(node) || node["type"] !== "StringLiteral") return undefined;
|
|
130
|
-
const value = node["value"];
|
|
131
|
-
return typeof value === "string" ? value : undefined;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function spanStart(node: Record<string, unknown>): number {
|
|
135
|
-
const span = node["span"];
|
|
136
|
-
if (!isRecord(span)) return 0;
|
|
137
|
-
const start = span["start"];
|
|
138
|
-
return typeof start === "number" ? start : 0;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
110
|
function importedNames(node: Record<string, unknown>): readonly string[] {
|
|
142
111
|
const specifiers = node["specifiers"];
|
|
143
112
|
if (!Array.isArray(specifiers)) return [];
|
|
@@ -197,10 +166,9 @@ function outOfProcessUse(node: Record<string, unknown>): string | undefined {
|
|
|
197
166
|
}
|
|
198
167
|
|
|
199
168
|
export const isolationViolations = Effect.fn("isolationViolations")(function* (file: string, source: string) {
|
|
200
|
-
const module = yield*
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
});
|
|
169
|
+
const module = yield* parseTypeScript(file, source).pipe(
|
|
170
|
+
Effect.mapError((error) => new LayoutError({ message: error.message })),
|
|
171
|
+
);
|
|
204
172
|
const violations: Violation[] = [];
|
|
205
173
|
const seen = new Set<unknown>();
|
|
206
174
|
|
|
@@ -228,7 +196,22 @@ export const isolationViolations = Effect.fn("isolationViolations")(function* (f
|
|
|
228
196
|
return violations;
|
|
229
197
|
});
|
|
230
198
|
|
|
231
|
-
|
|
199
|
+
function tierScriptViolation(scripts: unknown, files: readonly string[], tier: (typeof TEST_TIERS)[number]): Violation | undefined {
|
|
200
|
+
const file = "package.json";
|
|
201
|
+
const key = `test:${tier}`;
|
|
202
|
+
const found = isRecord(scripts) ? scripts[key] : undefined;
|
|
203
|
+
const expected = `${TEST_ENTRY_POINT.bin} --tier=${tier}`;
|
|
204
|
+
const hasTests = files.some((candidate) => candidate.startsWith(`tests/${tier}/`) && TEST_FILE.test(candidate));
|
|
205
|
+
if (!hasTests && found === undefined) return undefined;
|
|
206
|
+
if (found === expected) return undefined;
|
|
207
|
+
return {
|
|
208
|
+
file,
|
|
209
|
+
line: undefined,
|
|
210
|
+
message: `${key} must be "${expected}"${hasTests ? " when its test tier has files" : " when declared"}, found ${JSON.stringify(found ?? null)}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function scriptViolations(manifest: unknown, files: readonly string[] = []): readonly Violation[] {
|
|
232
215
|
const file = "package.json";
|
|
233
216
|
const scripts = isRecord(manifest) ? manifest["scripts"] : undefined;
|
|
234
217
|
const test = isRecord(scripts) ? scripts["test"] : undefined;
|
|
@@ -248,10 +231,14 @@ export function scriptViolations(manifest: unknown): readonly Violation[] {
|
|
|
248
231
|
message: `scripts.lint must run the layout check: add "${ENTRY_POINT.bin}"`,
|
|
249
232
|
});
|
|
250
233
|
}
|
|
234
|
+
for (const tier of TEST_TIERS) {
|
|
235
|
+
const violation = tierScriptViolation(scripts, files, tier);
|
|
236
|
+
if (violation !== undefined) violations.push(violation);
|
|
237
|
+
}
|
|
251
238
|
return violations;
|
|
252
239
|
}
|
|
253
240
|
|
|
254
|
-
export function bunfigViolations(consumer: unknown, preset: unknown
|
|
241
|
+
export function bunfigViolations(consumer: unknown, preset: unknown): readonly Violation[] {
|
|
255
242
|
const file = "bunfig.toml";
|
|
256
243
|
const copy = "bun has no bunfig extends, so copy node_modules/@avi2dg/checks/bunfig.toml";
|
|
257
244
|
if (consumer === undefined) {
|
|
@@ -272,10 +259,9 @@ export function bunfigViolations(consumer: unknown, preset: unknown, vendors: bo
|
|
|
272
259
|
for (const [key, value] of Object.entries(presetTest)) {
|
|
273
260
|
if (key !== IGNORES_KEY && !Bun.deepEquals(found(key), value)) violations.push(drifted(key, JSON.stringify(value), copy));
|
|
274
261
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
violations.push(drifted(IGNORES_KEY, wanted, `the check pins it, requiring ${VENDORED} where ${QUALITY_FILE} declares sources.libraries`));
|
|
262
|
+
if (!ACCEPTED_IGNORES.some((ignores) => Bun.deepEquals(found(IGNORES_KEY), ignores))) {
|
|
263
|
+
const wanted = ACCEPTED_IGNORES.map((ignores) => JSON.stringify(ignores)).join(" or ");
|
|
264
|
+
violations.push(drifted(IGNORES_KEY, wanted, `the check pins it, and a repository that links libraries under repos/ keeps ${VENDORED}`));
|
|
279
265
|
}
|
|
280
266
|
return violations;
|
|
281
267
|
}
|
|
@@ -318,7 +304,7 @@ export const run = Effect.fn("run")(function* (root: string, presetPath: string)
|
|
|
318
304
|
(file) =>
|
|
319
305
|
file.startsWith(TESTS) &&
|
|
320
306
|
TYPESCRIPT.test(file) &&
|
|
321
|
-
!file
|
|
307
|
+
!startsWithAny(file, OUT_OF_PROCESS) &&
|
|
322
308
|
!file.startsWith(DATA_DIR),
|
|
323
309
|
);
|
|
324
310
|
for (const file of inProcess) {
|
|
@@ -326,13 +312,11 @@ export const run = Effect.fn("run")(function* (root: string, presetPath: string)
|
|
|
326
312
|
violations.push(...(yield* isolationViolations(file, source)));
|
|
327
313
|
}
|
|
328
314
|
|
|
329
|
-
violations.push(...scriptViolations(yield* readManifest(path.join(root, "package.json"))));
|
|
315
|
+
violations.push(...scriptViolations(yield* readManifest(path.join(root, "package.json")), files));
|
|
330
316
|
|
|
331
317
|
const bunfig = path.join(root, "bunfig.toml");
|
|
332
318
|
const consumerBunfig = (yield* fs.exists(bunfig)) ? yield* parsedToml(bunfig) : undefined;
|
|
333
|
-
|
|
334
|
-
const vendors = (quality.sources?.libraries ?? []).length > 0;
|
|
335
|
-
violations.push(...bunfigViolations(consumerBunfig, yield* parsedToml(presetPath), vendors));
|
|
319
|
+
violations.push(...bunfigViolations(consumerBunfig, yield* parsedToml(presetPath)));
|
|
336
320
|
|
|
337
321
|
return { files: files.length, violations };
|
|
338
322
|
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "./swc.ts";
|
|
3
|
+
|
|
4
|
+
export type Environment = "ci" | "local";
|
|
5
|
+
export type TestTier = "live" | "pixel";
|
|
6
|
+
export type SkipDeclaration = {
|
|
7
|
+
readonly file: string;
|
|
8
|
+
readonly line: number;
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly reason: string;
|
|
11
|
+
readonly when?: Environment;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class SkipDeclarationError extends Schema.TaggedError<SkipDeclarationError>()("SkipDeclarationError", {
|
|
15
|
+
message: Schema.String,
|
|
16
|
+
}) {}
|
|
17
|
+
|
|
18
|
+
const TEST_FILES = ["tests/**/*.test.ts", "tests/**/*.test.tsx"] as const;
|
|
19
|
+
const SKIP_HELPER_MODULE = "test-skips.ts";
|
|
20
|
+
const whenSchema = Schema.Literals(["ci", "local"]);
|
|
21
|
+
|
|
22
|
+
export function skipReason(_reason: string, label: string, _when?: Environment): string {
|
|
23
|
+
return label;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function expressionOf(argument: unknown): unknown {
|
|
27
|
+
return isRecord(argument) ? argument["expression"] : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function argumentsOf(node: Record<string, unknown>): readonly unknown[] {
|
|
31
|
+
const args = node["arguments"];
|
|
32
|
+
return Array.isArray(args) ? args : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function skipReasonAliases(statement: unknown): readonly string[] {
|
|
36
|
+
if (!isRecord(statement) || statement["type"] !== "ImportDeclaration") return [];
|
|
37
|
+
const source = stringValue(statement["source"]);
|
|
38
|
+
const specifiers = statement["specifiers"];
|
|
39
|
+
if (source === undefined || !source.endsWith(SKIP_HELPER_MODULE) || !Array.isArray(specifiers)) return [];
|
|
40
|
+
return specifiers.flatMap((specifier) => {
|
|
41
|
+
if (!isRecord(specifier) || specifier["type"] !== "ImportSpecifier") return [];
|
|
42
|
+
const imported = identifierName(specifier["imported"]) ?? identifierName(specifier["local"]);
|
|
43
|
+
const local = identifierName(specifier["local"]);
|
|
44
|
+
return imported === "skipReason" && local !== undefined ? [local] : [];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function importedSkipReasonNames(module: unknown): ReadonlySet<string> {
|
|
49
|
+
if (!isRecord(module) || !Array.isArray(module["body"])) return new Set();
|
|
50
|
+
return new Set(module["body"].flatMap(skipReasonAliases));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Member = { readonly object: string | undefined; readonly property: string | undefined };
|
|
54
|
+
|
|
55
|
+
function memberOf(node: unknown): Member | undefined {
|
|
56
|
+
if (!isRecord(node) || node["type"] !== "MemberExpression") return undefined;
|
|
57
|
+
return { object: identifierName(node["object"]), property: identifierName(node["property"]) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type SkipReasonScan =
|
|
61
|
+
| { readonly kind: "none" }
|
|
62
|
+
| { readonly kind: "invalid"; readonly message: string }
|
|
63
|
+
| { readonly kind: "valid"; readonly name: string; readonly reason: string; readonly when?: Environment };
|
|
64
|
+
|
|
65
|
+
function skipReasonCall(node: unknown, aliases: ReadonlySet<string>): SkipReasonScan {
|
|
66
|
+
if (!isRecord(node) || node["type"] !== "CallExpression") return { kind: "none" };
|
|
67
|
+
if (!aliases.has(identifierName(node["callee"]) ?? "")) return { kind: "none" };
|
|
68
|
+
const [reasonArgument, labelArgument, whenArgument] = argumentsOf(node);
|
|
69
|
+
const reason = stringValue(expressionOf(reasonArgument));
|
|
70
|
+
const label = stringValue(expressionOf(labelArgument));
|
|
71
|
+
if (reason === undefined || reason.trim() === "" || label === undefined || label.trim() === "") {
|
|
72
|
+
return { kind: "invalid", message: "skipReason needs a non-empty literal reason and a literal test name" };
|
|
73
|
+
}
|
|
74
|
+
const when = whenArgument === undefined ? undefined : stringValue(expressionOf(whenArgument));
|
|
75
|
+
if (whenArgument !== undefined && when === undefined) {
|
|
76
|
+
return { kind: "invalid", message: "skipReason's when value must be \"ci\" or \"local\"" };
|
|
77
|
+
}
|
|
78
|
+
if (when !== undefined && !Schema.is(whenSchema)(when)) {
|
|
79
|
+
return {
|
|
80
|
+
kind: "invalid",
|
|
81
|
+
message: `skipReason's when value must be "ci" or "local", found ${JSON.stringify(when)}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { kind: "valid", name: label, reason, ...(when === undefined ? {} : { when }) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type RegisteredSkip = { readonly onDescribe: boolean; readonly first: unknown };
|
|
88
|
+
|
|
89
|
+
function registeredSkip(node: Record<string, unknown>): RegisteredSkip | undefined {
|
|
90
|
+
if (node["type"] !== "CallExpression") return undefined;
|
|
91
|
+
const callee = node["callee"];
|
|
92
|
+
const curried = isRecord(callee) && callee["type"] === "CallExpression";
|
|
93
|
+
const member = memberOf(curried ? callee["callee"] : callee);
|
|
94
|
+
const methods = curried ? ["skipIf", "if"] : ["skip", "todo"];
|
|
95
|
+
if (member === undefined || !methods.includes(member.property ?? "")) return undefined;
|
|
96
|
+
return { onDescribe: member.object === "describe", first: expressionOf(argumentsOf(node)[0]) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function registeredSkips(module: unknown): readonly RegisteredSkip[] {
|
|
100
|
+
const found: RegisteredSkip[] = [];
|
|
101
|
+
const visit = (node: unknown): void => {
|
|
102
|
+
if (Array.isArray(node)) {
|
|
103
|
+
for (const child of node) visit(child);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!isRecord(node)) return;
|
|
107
|
+
const registered = registeredSkip(node);
|
|
108
|
+
if (registered !== undefined) found.push(registered);
|
|
109
|
+
for (const value of Object.values(node)) visit(value);
|
|
110
|
+
};
|
|
111
|
+
visit(module);
|
|
112
|
+
return found;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type DeclarationsRead =
|
|
116
|
+
| { readonly kind: "valid"; readonly declarations: readonly SkipDeclaration[] }
|
|
117
|
+
| { readonly kind: "invalid"; readonly message: string };
|
|
118
|
+
|
|
119
|
+
function declarationsIn(source: string, file: string, module: unknown): DeclarationsRead {
|
|
120
|
+
const aliases = importedSkipReasonNames(module);
|
|
121
|
+
if (aliases.size === 0) return { kind: "valid", declarations: [] };
|
|
122
|
+
const declarations: SkipDeclaration[] = [];
|
|
123
|
+
for (const { onDescribe, first } of registeredSkips(module)) {
|
|
124
|
+
if (!isRecord(first)) continue;
|
|
125
|
+
const metadata = skipReasonCall(first, aliases);
|
|
126
|
+
if (metadata.kind === "none") continue;
|
|
127
|
+
const line = lineOf(source, spanStart(first));
|
|
128
|
+
if (onDescribe) {
|
|
129
|
+
return {
|
|
130
|
+
kind: "invalid",
|
|
131
|
+
message: `line ${line}: skipReason cannot declare a describe; declare each test inside it with its own skipReason`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (metadata.kind === "invalid") return metadata;
|
|
135
|
+
const { name, reason, when } = metadata;
|
|
136
|
+
declarations.push({ file, line, name, reason, ...(when === undefined ? {} : { when }) });
|
|
137
|
+
}
|
|
138
|
+
return { kind: "valid", declarations };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function selectedTierFile(file: string, tier: TestTier | undefined): boolean {
|
|
142
|
+
if (file.startsWith("tests/quarantine/")) return false;
|
|
143
|
+
if (tier === undefined) return !file.startsWith("tests/live/") && !file.startsWith("tests/pixel/");
|
|
144
|
+
return file.startsWith(`tests/${tier}/`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const readSkipDeclarations = Effect.fn("readSkipDeclarations")(function* (
|
|
148
|
+
root: string,
|
|
149
|
+
tier: TestTier | undefined,
|
|
150
|
+
) {
|
|
151
|
+
const fs = yield* FileSystem.FileSystem;
|
|
152
|
+
const path = yield* Path.Path;
|
|
153
|
+
const files = yield* Effect.tryPromise({
|
|
154
|
+
try: () =>
|
|
155
|
+
Promise.all(
|
|
156
|
+
TEST_FILES.map((pattern) =>
|
|
157
|
+
Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: root, onlyFiles: true })),
|
|
158
|
+
),
|
|
159
|
+
).then((groups) => groups.flat().filter((file) => selectedTierFile(file, tier)).sort()),
|
|
160
|
+
catch: (cause) => new SkipDeclarationError({ message: `cannot find test files: ${String(cause)}` }),
|
|
161
|
+
});
|
|
162
|
+
const declarations: SkipDeclaration[] = [];
|
|
163
|
+
for (const file of files) {
|
|
164
|
+
const source = yield* fs.readFileString(path.join(root, file)).pipe(
|
|
165
|
+
Effect.mapError((cause) => new SkipDeclarationError({ message: `cannot read ${file}: ${cause.message}` })),
|
|
166
|
+
);
|
|
167
|
+
const module = yield* parseTypeScript(file, source).pipe(
|
|
168
|
+
Effect.mapError((error) => new SkipDeclarationError({ message: error.message })),
|
|
169
|
+
);
|
|
170
|
+
const parsed = yield* Effect.try({
|
|
171
|
+
try: () => declarationsIn(source, file, module),
|
|
172
|
+
catch: (cause) => new SkipDeclarationError({ message: `cannot read skip declarations in ${file}: ${String(cause)}` }),
|
|
173
|
+
});
|
|
174
|
+
if (parsed.kind === "invalid") {
|
|
175
|
+
return yield* new SkipDeclarationError({ message: `${file}: ${parsed.message}` });
|
|
176
|
+
}
|
|
177
|
+
declarations.push(...parsed.declarations);
|
|
178
|
+
}
|
|
179
|
+
return declarations;
|
|
180
|
+
});
|