@avi2dg/checks 0.8.0 → 0.10.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/README.md +253 -29
- package/oxlintrc.json +2 -2
- package/package.json +15 -3
- package/scripts/backtest.ts +2 -3
- package/scripts/ci-wiring.ts +134 -36
- package/scripts/comment-gate.ts +4 -7
- package/scripts/comment-matchers.ts +309 -0
- package/scripts/comments.ts +7 -295
- package/scripts/flake.ts +153 -0
- package/scripts/gates.ts +48 -8
- package/scripts/git.ts +14 -0
- package/scripts/lint-coverage.sh +11 -5
- package/scripts/lint.ts +44 -27
- package/scripts/suppressions-ratchet.ts +10 -5
- package/scripts/test-layout.ts +4 -4
- package/scripts/test-report.ts +115 -0
- package/scripts/test.ts +153 -0
package/scripts/lint.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
4
|
-
import { DEFAULT_BRANCH,
|
|
4
|
+
import { DEFAULT_BRANCH, LintWiring, selectedGates, type KitGate } from "./gates.ts";
|
|
5
5
|
import { git } from "./git.ts";
|
|
6
6
|
import { runMain, Usage } from "./main.ts";
|
|
7
7
|
|
|
@@ -20,6 +20,10 @@ class GatesUndecided extends Schema.TaggedError<GatesUndecided>()("GatesUndecide
|
|
|
20
20
|
message: Schema.String,
|
|
21
21
|
}) {}
|
|
22
22
|
|
|
23
|
+
class WiringUnreadable extends Schema.TaggedError<WiringUnreadable>()("WiringUnreadable", {
|
|
24
|
+
message: Schema.String,
|
|
25
|
+
}) {}
|
|
26
|
+
|
|
23
27
|
const NAME = "checks-lint";
|
|
24
28
|
const USAGE = "usage: lint.ts [<base-ref> <head-ref>]";
|
|
25
29
|
const PULL_REQUEST_EVENT = "pull_request";
|
|
@@ -52,30 +56,38 @@ const pullRequestEnds = Effect.fn("pullRequestEnds")(function* (eventPath: strin
|
|
|
52
56
|
};
|
|
53
57
|
});
|
|
54
58
|
|
|
55
|
-
const
|
|
56
|
-
Schema.fromJsonString(
|
|
57
|
-
Schema.Struct({
|
|
58
|
-
ciWiring: Schema.optionalKey(Schema.Struct({ defaultBranch: Schema.optionalKey(Schema.NonEmptyString) })),
|
|
59
|
-
}),
|
|
60
|
-
),
|
|
61
|
-
);
|
|
59
|
+
const decodeWiring = Schema.decodeUnknownEffect(Schema.fromJsonString(LintWiring));
|
|
62
60
|
|
|
63
|
-
const
|
|
61
|
+
const readWiring = Effect.gen(function* () {
|
|
64
62
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
65
63
|
const manifest = (yield* Path.Path).join(root, "package.json");
|
|
66
|
-
const { ciWiring } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(
|
|
67
|
-
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
const { ciWiring } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(
|
|
65
|
+
Effect.flatMap(decodeWiring),
|
|
66
|
+
Effect.mapError((cause) => new WiringUnreadable({ message: `cannot read ciWiring from ${manifest}: ${cause.message}` })),
|
|
67
|
+
);
|
|
68
|
+
return { defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH, lintGates: ciWiring?.lintGates };
|
|
69
|
+
});
|
|
71
70
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
71
|
+
const originEnds = (defaultBranch: string) =>
|
|
72
|
+
git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).pipe(
|
|
73
|
+
Effect.map((ref) => ref.trim()),
|
|
74
|
+
Effect.catchTag("GitFailure", () => Effect.succeed(`origin/${defaultBranch}`)),
|
|
75
|
+
Effect.map((base) => ({ base, head: "HEAD", source: `HEAD against ${base}` })),
|
|
76
|
+
);
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
// A clone holding any remote-tracking ref but not the default branch is a shallow CI checkout,
|
|
79
|
+
// where judging HEAD alone would pass every commit before it unchecked.
|
|
80
|
+
const localEnds = Effect.fn("localEnds")(function* (defaultBranch: string) {
|
|
81
|
+
const remoteTracking = yield* git(["for-each-ref", "--count=1", "refs/remotes/"]).pipe(
|
|
82
|
+
Effect.mapError((cause) => new RangeUnresolved({ message: cause.message })),
|
|
83
|
+
);
|
|
84
|
+
if (remoteTracking.trim() === "") {
|
|
85
|
+
return { base: "HEAD", head: "HEAD", source: "HEAD alone, as the clone has no remote-tracking refs" };
|
|
86
|
+
}
|
|
87
|
+
return yield* originEnds(defaultBranch);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const endsOf = Effect.fn("endsOf")(function* (args: readonly string[], defaultBranch: string) {
|
|
79
91
|
const [base, head, ...extra] = args;
|
|
80
92
|
if (base !== undefined && head !== undefined && extra.length === 0) {
|
|
81
93
|
return { base, head, source: `${head} against ${base}` };
|
|
@@ -92,7 +104,7 @@ const endsOf = Effect.fn("endsOf")(function* (args: readonly string[]) {
|
|
|
92
104
|
}
|
|
93
105
|
return yield* pullRequestEnds(event.path.value);
|
|
94
106
|
}
|
|
95
|
-
return yield* localEnds;
|
|
107
|
+
return yield* localEnds(defaultBranch);
|
|
96
108
|
});
|
|
97
109
|
|
|
98
110
|
const commitOf = (ref: string) =>
|
|
@@ -105,8 +117,8 @@ const commitOf = (ref: string) =>
|
|
|
105
117
|
|
|
106
118
|
// From the base branch's tip, every range gate would charge the head with the commits the base
|
|
107
119
|
// branch gained after the head branched off.
|
|
108
|
-
const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[]) {
|
|
109
|
-
const ends = yield* endsOf(args);
|
|
120
|
+
const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[], defaultBranch: string) {
|
|
121
|
+
const ends = yield* endsOf(args, defaultBranch);
|
|
110
122
|
const head = yield* commitOf(ends.head);
|
|
111
123
|
const base = yield* git(["merge-base", yield* commitOf(ends.base), head]).pipe(
|
|
112
124
|
Effect.map((sha) => sha.trim()),
|
|
@@ -142,16 +154,21 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
|
|
|
142
154
|
});
|
|
143
155
|
|
|
144
156
|
const lint = Effect.gen(function* () {
|
|
145
|
-
const
|
|
157
|
+
const { defaultBranch, lintGates } = yield* readWiring;
|
|
158
|
+
const range = yield* resolveRange(process.argv.slice(2), defaultBranch);
|
|
146
159
|
yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
|
|
160
|
+
const gates = selectedGates(lintGates);
|
|
161
|
+
if (lintGates !== undefined) {
|
|
162
|
+
yield* Console.log(`${NAME}: ciWiring.lintGates selects ${gates.map((gate) => gate.bin).join(", ")}`);
|
|
163
|
+
}
|
|
147
164
|
|
|
148
|
-
const verdicts = yield* Effect.forEach(
|
|
165
|
+
const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range));
|
|
149
166
|
const failed = verdicts.filter((verdict) => verdict.outcome !== "passed");
|
|
150
167
|
if (failed.length === 0) {
|
|
151
|
-
yield* Console.log(`${NAME}: ${
|
|
168
|
+
yield* Console.log(`${NAME}: ${gates.length} gate(s) pass`);
|
|
152
169
|
return true;
|
|
153
170
|
}
|
|
154
|
-
const summary = `${failed.length} of ${
|
|
171
|
+
const summary = `${failed.length} of ${gates.length} gate(s) failed: ${failed.map((verdict) => verdict.gate.bin).join(", ")}`;
|
|
155
172
|
if (failed.every((verdict) => verdict.outcome === "undecided")) {
|
|
156
173
|
return yield* new GatesUndecided({ message: summary });
|
|
157
174
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
|
-
import { git } from "./git.ts";
|
|
3
|
+
import { git, parentOrEmptyTree } from "./git.ts";
|
|
4
4
|
import { runMain, Usage } from "./main.ts";
|
|
5
5
|
|
|
6
6
|
export const SUPPRESSIONS = "oxlint-suppressions.json";
|
|
@@ -99,10 +99,10 @@ const mergeBase = Effect.fn("mergeBase")(function* (base: string, head: string)
|
|
|
99
99
|
return commit.trim();
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
const suppressionsAt = Effect.fn("suppressionsAt")(function* (
|
|
103
|
-
const blob = (yield* git(["ls-tree", "--object-only",
|
|
102
|
+
const suppressionsAt = Effect.fn("suppressionsAt")(function* (treeish: string) {
|
|
103
|
+
const blob = (yield* git(["ls-tree", "--object-only", treeish, "--", SUPPRESSIONS])).trim();
|
|
104
104
|
if (blob === "") return new Map() satisfies Suppressions;
|
|
105
|
-
return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${SUPPRESSIONS} at ${
|
|
105
|
+
return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${SUPPRESSIONS} at ${treeish}`);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
export const runRange = Effect.fn("runRange")(function* (base: string, head: string) {
|
|
@@ -112,11 +112,16 @@ export const runRange = Effect.fn("runRange")(function* (base: string, head: str
|
|
|
112
112
|
return compareSuppressions(yield* suppressionsAt(branchPoint), yield* suppressionsAt(headCommit));
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
export const runTip = Effect.fn("runTip")(function* (tip: string) {
|
|
116
|
+
const tipCommit = yield* commitOf(tip);
|
|
117
|
+
return compareSuppressions(yield* suppressionsAt(yield* parentOrEmptyTree(tipCommit)), yield* suppressionsAt(tipCommit));
|
|
118
|
+
});
|
|
119
|
+
|
|
115
120
|
const ratchet = Effect.gen(function* () {
|
|
116
121
|
const [first, second, ...extra] = process.argv.slice(2);
|
|
117
122
|
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
118
123
|
|
|
119
|
-
const result = second !== undefined ? yield* runRange(first, second) : yield*
|
|
124
|
+
const result = second !== undefined ? yield* runRange(first, second) : yield* runTip(first);
|
|
120
125
|
|
|
121
126
|
yield* Console.log(report(result));
|
|
122
127
|
return result.rises.length === 0;
|
package/scripts/test-layout.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { parse } from "@swc/core";
|
|
|
3
3
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
4
4
|
import { git } from "./git.ts";
|
|
5
5
|
import { runMain } from "./main.ts";
|
|
6
|
-
import { ENTRY_POINT } from "./gates.ts";
|
|
6
|
+
import { ENTRY_POINT, TEST_ENTRY_POINT } from "./gates.ts";
|
|
7
7
|
|
|
8
8
|
export type Violation = {
|
|
9
9
|
readonly file: string;
|
|
@@ -39,13 +39,13 @@ const BANNED_BUN_NAMES: readonly string[] = [
|
|
|
39
39
|
];
|
|
40
40
|
const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
|
|
41
41
|
|
|
42
|
-
export const REQUIRED_TEST_SCRIPT = "bun test --randomize";
|
|
43
42
|
const REQUIRED_TEST_TABLE: Record<string, unknown> = {
|
|
44
43
|
pathIgnorePatterns: ["**/tests/quarantine/**"],
|
|
45
44
|
};
|
|
46
45
|
export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
|
|
47
46
|
export const LAYOUT_CHECK_BIN = "checks-test-layout";
|
|
48
47
|
const OWN_ENTRY_POINT = `scripts/${ENTRY_POINT.script}`;
|
|
48
|
+
const TEST_SCRIPTS: readonly string[] = [TEST_ENTRY_POINT.bin, `bun scripts/${TEST_ENTRY_POINT.script}`];
|
|
49
49
|
|
|
50
50
|
export class LayoutError extends Schema.TaggedError<LayoutError>()("LayoutError", {
|
|
51
51
|
message: Schema.String,
|
|
@@ -226,11 +226,11 @@ export function scriptViolations(manifest: unknown): readonly Violation[] {
|
|
|
226
226
|
const test = isRecord(scripts) ? scripts["test"] : undefined;
|
|
227
227
|
const lint = isRecord(scripts) ? scripts["lint"] : undefined;
|
|
228
228
|
const violations: Violation[] = [];
|
|
229
|
-
if (test !==
|
|
229
|
+
if (typeof test !== "string" || !TEST_SCRIPTS.includes(test)) {
|
|
230
230
|
violations.push({
|
|
231
231
|
file,
|
|
232
232
|
line: undefined,
|
|
233
|
-
message: `scripts.test must be exactly "${
|
|
233
|
+
message: `scripts.test must be exactly "${TEST_ENTRY_POINT.bin}", which runs bun test --randomize and judges its skips, found ${JSON.stringify(test ?? null)}`,
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
if (typeof lint !== "string" || !runsLayoutCheck(lint)) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export type Outcome = "passed" | "failed" | "skipped" | "todo";
|
|
4
|
+
|
|
5
|
+
export type TestResult = {
|
|
6
|
+
readonly file: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly line: number;
|
|
9
|
+
readonly outcome: Outcome;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class ReportError extends Schema.TaggedError<ReportError>()("ReportError", {
|
|
13
|
+
message: Schema.String,
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
type Element = {
|
|
17
|
+
readonly tag: string;
|
|
18
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
19
|
+
readonly children: Element[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// bun's console prints a test inside describe blocks as "outer > inner > name".
|
|
23
|
+
export const NAME_SEPARATOR = " > ";
|
|
24
|
+
|
|
25
|
+
export function reporterArgs(outfile: string): readonly string[] {
|
|
26
|
+
return ["--reporter=junit", `--reporter-outfile=${outfile}`];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MARKUP =
|
|
30
|
+
/<(?:\?[\s\S]*?\?>|!--[\s\S]*?-->|!\[CDATA\[[\s\S]*?\]\]>|(\/?)([A-Za-z_][\w.:-]*)((?:\s+[A-Za-z_][\w.:-]*\s*=\s*"[^"<]*")*)\s*(\/?)>)/g;
|
|
31
|
+
const ATTRIBUTE = /([A-Za-z_][\w.:-]*)\s*=\s*"([^"<]*)"/g;
|
|
32
|
+
const ENTITY = /&(?:(amp|lt|gt|quot|apos)|#(\d+)|#x([0-9A-Fa-f]+));/g;
|
|
33
|
+
const NAMED_ENTITIES: Readonly<Record<string, string>> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
34
|
+
|
|
35
|
+
const decodeSuite = Schema.decodeUnknownEffect(Schema.Struct({ name: Schema.String }));
|
|
36
|
+
const decodeCase = Schema.decodeUnknownEffect(
|
|
37
|
+
Schema.Struct({ name: Schema.String, file: Schema.String, line: Schema.NumberFromString.check(Schema.isInt()) }),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function unescape(text: string): string {
|
|
41
|
+
return text.replace(ENTITY, (_, named: string | undefined, decimal: string | undefined, hex: string | undefined) => {
|
|
42
|
+
if (named !== undefined) return NAMED_ENTITIES[named] ?? "";
|
|
43
|
+
return String.fromCodePoint(decimal === undefined ? Number.parseInt(hex ?? "", 16) : Number.parseInt(decimal, 10));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function attributesOf(source: string): Readonly<Record<string, string>> {
|
|
48
|
+
const attributes: Record<string, string> = {};
|
|
49
|
+
for (const [, name, value] of source.matchAll(ATTRIBUTE)) {
|
|
50
|
+
if (name !== undefined && value !== undefined) attributes[name] = unescape(value);
|
|
51
|
+
}
|
|
52
|
+
return attributes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const parseElements = Effect.fnUntraced(function* (xml: string): Effect.fn.Return<Element, ReportError> {
|
|
56
|
+
const malformed = (reason: string) => new ReportError({ message: `the junit report is malformed: ${reason}` });
|
|
57
|
+
const document: Element = { tag: "", attributes: {}, children: [] };
|
|
58
|
+
const open: Element[] = [document];
|
|
59
|
+
let consumed = 0;
|
|
60
|
+
for (const match of xml.matchAll(MARKUP)) {
|
|
61
|
+
if (xml.slice(consumed, match.index).includes("<")) return yield* malformed(`stray < before offset ${match.index}`);
|
|
62
|
+
consumed = match.index + match[0].length;
|
|
63
|
+
const [, closing, tag, attributes, selfClosing] = match;
|
|
64
|
+
if (tag === undefined) continue;
|
|
65
|
+
const parent = open.at(-1);
|
|
66
|
+
if (parent === undefined) return yield* malformed(`<${tag}> after the root closed`);
|
|
67
|
+
if (closing === "/") {
|
|
68
|
+
if (parent.tag !== tag) return yield* malformed(`</${tag}> closes <${parent.tag}>`);
|
|
69
|
+
open.pop();
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const element: Element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
|
|
73
|
+
parent.children.push(element);
|
|
74
|
+
if (selfClosing !== "/") open.push(element);
|
|
75
|
+
}
|
|
76
|
+
if (xml.slice(consumed).includes("<")) return yield* malformed("stray < after the last element");
|
|
77
|
+
if (open.length !== 1) return yield* malformed(`<${open.at(-1)?.tag}> never closes`);
|
|
78
|
+
const [root, ...extra] = document.children;
|
|
79
|
+
if (root?.tag !== "testsuites" || extra.length > 0) return yield* malformed("the root is not one <testsuites>");
|
|
80
|
+
return root;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function outcomeOf(testcase: Element): Outcome {
|
|
84
|
+
const skipped = testcase.children.find((child) => child.tag === "skipped");
|
|
85
|
+
if (skipped !== undefined) return skipped.attributes["message"] === "TODO" ? "todo" : "skipped";
|
|
86
|
+
return testcase.children.some((child) => child.tag === "failure" || child.tag === "error") ? "failed" : "passed";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const collect = Effect.fnUntraced(function* (
|
|
90
|
+
suite: Element,
|
|
91
|
+
describes: readonly string[],
|
|
92
|
+
results: TestResult[],
|
|
93
|
+
): Effect.fn.Return<void, ReportError> {
|
|
94
|
+
const unreadable = (tag: string) => (cause: { readonly message: string }) =>
|
|
95
|
+
new ReportError({ message: `the junit report holds a <${tag}> bun never writes: ${cause.message}` });
|
|
96
|
+
for (const child of suite.children) {
|
|
97
|
+
if (child.tag === "testsuite") {
|
|
98
|
+
const { name } = yield* decodeSuite(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
|
|
99
|
+
yield* collect(child, [...describes, name], results);
|
|
100
|
+
} else if (child.tag === "testcase") {
|
|
101
|
+
const { name, file, line } = yield* decodeCase(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
|
|
102
|
+
results.push({ file, name: [...describes, name].join(NAME_SEPARATOR), line, outcome: outcomeOf(child) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// A top-level <testsuite> is named for its file, so only the describe blocks nested in it join a test's name.
|
|
108
|
+
export const parseReport = Effect.fn("parseReport")(function* (xml: string) {
|
|
109
|
+
const root = yield* parseElements(xml);
|
|
110
|
+
const results: TestResult[] = [];
|
|
111
|
+
for (const fileSuite of root.children) {
|
|
112
|
+
if (fileSuite.tag === "testsuite") yield* collect(fileSuite, [], results);
|
|
113
|
+
}
|
|
114
|
+
return results;
|
|
115
|
+
});
|
package/scripts/test.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
4
|
+
import { TEST_ENTRY_POINT } from "./gates.ts";
|
|
5
|
+
import { runMain, Usage } from "./main.ts";
|
|
6
|
+
import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
|
|
7
|
+
|
|
8
|
+
const Environment = Schema.Literals(["ci", "local"]);
|
|
9
|
+
|
|
10
|
+
export type Environment = typeof Environment.Type;
|
|
11
|
+
|
|
12
|
+
const SkipDeclaration = Schema.Struct({
|
|
13
|
+
file: Schema.NonEmptyString,
|
|
14
|
+
test: Schema.NonEmptyString,
|
|
15
|
+
reason: Schema.NonEmptyString,
|
|
16
|
+
when: Schema.optionalKey(Environment),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type SkipDeclaration = typeof SkipDeclaration.Type;
|
|
20
|
+
|
|
21
|
+
export type Verdict = {
|
|
22
|
+
readonly environment: Environment;
|
|
23
|
+
readonly skipped: number;
|
|
24
|
+
readonly undeclared: readonly TestResult[];
|
|
25
|
+
readonly stale: readonly SkipDeclaration[];
|
|
26
|
+
readonly unjudged: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class SkipGateError extends Schema.TaggedError<SkipGateError>()("SkipGateError", {
|
|
30
|
+
message: Schema.String,
|
|
31
|
+
}) {}
|
|
32
|
+
|
|
33
|
+
const NAME = TEST_ENTRY_POINT.bin;
|
|
34
|
+
const DECLARATIONS = "package.json testSkips";
|
|
35
|
+
const USAGE = `usage: ${NAME} takes no arguments, since a narrowed run skips every test it leaves out; run bun test --randomize <args> for one`;
|
|
36
|
+
|
|
37
|
+
const decodeManifest = Schema.decodeUnknownEffect(
|
|
38
|
+
Schema.fromJsonString(Schema.Struct({ testSkips: Schema.optionalKey(Schema.Array(SkipDeclaration)) })),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
function matches(declaration: SkipDeclaration, result: TestResult): boolean {
|
|
42
|
+
return declaration.file === result.file && declaration.test === result.name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function byPlace(a: TestResult, b: TestResult): number {
|
|
46
|
+
return a.file.localeCompare(b.file) || a.line - b.line || a.name.localeCompare(b.name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function judgeSkips(
|
|
50
|
+
results: readonly TestResult[],
|
|
51
|
+
declarations: readonly SkipDeclaration[],
|
|
52
|
+
environment: Environment,
|
|
53
|
+
): Verdict {
|
|
54
|
+
const skipped = results.filter((result) => result.outcome === "skipped" || result.outcome === "todo");
|
|
55
|
+
const applying = declarations.filter((declaration) => (declaration.when ?? environment) === environment);
|
|
56
|
+
return {
|
|
57
|
+
environment,
|
|
58
|
+
skipped: skipped.length,
|
|
59
|
+
undeclared: skipped.filter((result) => !applying.some((declaration) => matches(declaration, result))).sort(byPlace),
|
|
60
|
+
stale: applying.filter((declaration) => !skipped.some((result) => matches(declaration, result))),
|
|
61
|
+
unjudged: declarations.length - applying.length,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function refusedStale(verdict: Verdict): readonly SkipDeclaration[] {
|
|
66
|
+
return verdict.environment === "ci" ? verdict.stale : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function passes(verdict: Verdict): boolean {
|
|
70
|
+
return verdict.undeclared.length === 0 && refusedStale(verdict).length === 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function staleLine(declaration: SkipDeclaration): string {
|
|
74
|
+
return ` ${declaration.file}${NAME_SEPARATOR}${declaration.test}: declared, but no such test skipped; delete the declaration`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function verdictLines(verdict: Verdict): readonly string[] {
|
|
78
|
+
const { environment, skipped, undeclared, unjudged } = verdict;
|
|
79
|
+
const run = `this ${environment} run`;
|
|
80
|
+
if (passes(verdict)) {
|
|
81
|
+
const other = environment === "ci" ? "local" : "ci";
|
|
82
|
+
const aside = unjudged === 0 ? "" : `; ${unjudged} declaration(s) for ${other} not judged in ${run}`;
|
|
83
|
+
return [skipped === 0 ? `${NAME}: no test skipped${aside}` : `${NAME}: ${skipped} skipped test(s), each declared in ${DECLARATIONS}${aside}`];
|
|
84
|
+
}
|
|
85
|
+
const stale = refusedStale(verdict);
|
|
86
|
+
const counted = environment === "ci" ? ` and ${stale.length} declaration(s) matching no skipped test` : "";
|
|
87
|
+
return [
|
|
88
|
+
`${NAME}: ${undeclared.length} skipped test(s) undeclared${counted} in ${run}:`,
|
|
89
|
+
...undeclared.map((result) => {
|
|
90
|
+
const kind = result.outcome === "todo" ? "a todo" : "skipped";
|
|
91
|
+
return ` ${result.file}:${result.line} ${result.name}: ${kind} with no declaration; run it, or declare it in ${DECLARATIONS} with its reason`;
|
|
92
|
+
}),
|
|
93
|
+
...stale.map(staleLine),
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function report(verdict: Verdict): string {
|
|
98
|
+
const warned = verdict.environment === "local" ? verdict.stale : [];
|
|
99
|
+
const warning =
|
|
100
|
+
warned.length === 0
|
|
101
|
+
? []
|
|
102
|
+
: [
|
|
103
|
+
`${NAME}: warning: ${warned.length} declaration(s) matching no skipped test in this local run, refused only in a ci run:`,
|
|
104
|
+
...warned.map(staleLine),
|
|
105
|
+
];
|
|
106
|
+
return [...verdictLines(verdict), ...warning].join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const readDeclarations = Effect.fn("readDeclarations")(function* (root: string) {
|
|
110
|
+
const manifest = (yield* Path.Path).join(root, "package.json");
|
|
111
|
+
const { testSkips } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(
|
|
112
|
+
Effect.flatMap(decodeManifest),
|
|
113
|
+
Effect.mapError((cause) => new SkipGateError({ message: `cannot read ${DECLARATIONS}: ${cause.message}` })),
|
|
114
|
+
);
|
|
115
|
+
return testSkips ?? [];
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const environmentOf = Config.Boolean("CI").pipe(
|
|
119
|
+
Config.withDefault(false),
|
|
120
|
+
Config.map((ci): Environment => (ci ? "ci" : "local")),
|
|
121
|
+
Effect.mapError((cause) => new SkipGateError({ message: `cannot tell a ci run from a local one: ${cause.message}` })),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const runSuite = Effect.fn("runSuite")(function* (outfile: string) {
|
|
125
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
126
|
+
const args = ["test", "--randomize", ...reporterArgs(outfile)];
|
|
127
|
+
return yield* spawner.exitCode(
|
|
128
|
+
ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const testEntry = Effect.gen(function* () {
|
|
133
|
+
if (process.argv.length > 2) return yield* new Usage({ message: USAGE });
|
|
134
|
+
const fs = yield* FileSystem.FileSystem;
|
|
135
|
+
const declarations = yield* readDeclarations(process.cwd());
|
|
136
|
+
const environment = yield* environmentOf;
|
|
137
|
+
|
|
138
|
+
const outfile = (yield* Path.Path).join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-" }), "junit.xml");
|
|
139
|
+
const suitePassed = (yield* runSuite(outfile)) === ChildProcessSpawner.ExitCode(0);
|
|
140
|
+
if (!(yield* fs.exists(outfile))) {
|
|
141
|
+
if (!suitePassed) {
|
|
142
|
+
yield* Console.error(`${NAME}: bun test failed before it wrote a report, so no skip was judged`);
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return yield* new ReportError({ message: `bun test passed but wrote no report to ${outfile}` });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const verdict = judgeSkips(yield* parseReport(yield* fs.readFileString(outfile)), declarations, environment);
|
|
149
|
+
yield* passes(verdict) ? Console.log(report(verdict)) : Console.error(report(verdict));
|
|
150
|
+
return suitePassed && passes(verdict);
|
|
151
|
+
}).pipe(Effect.scoped);
|
|
152
|
+
|
|
153
|
+
if (import.meta.main) runMain(NAME, testEntry);
|