@avi2dg/checks 0.5.0 → 0.8.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 +193 -67
- package/package.json +16 -6
- package/scripts/backtest.ts +23 -33
- package/scripts/ci-wiring.ts +86 -65
- package/scripts/comment-gate.ts +23 -38
- package/scripts/comments.ts +24 -13
- package/scripts/commit-identity.ts +91 -87
- package/scripts/gates.ts +21 -0
- package/scripts/git.ts +27 -0
- package/scripts/lint.ts +162 -0
- package/scripts/main.ts +28 -0
- package/scripts/mutation-compare.ts +43 -57
- package/scripts/suppressions-ratchet.ts +125 -0
- package/scripts/test-layout.ts +67 -44
package/scripts/lint.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
4
|
+
import { DEFAULT_BRANCH, KIT_GATES, type KitGate } from "./gates.ts";
|
|
5
|
+
import { git } from "./git.ts";
|
|
6
|
+
import { runMain, Usage } from "./main.ts";
|
|
7
|
+
|
|
8
|
+
type Range = {
|
|
9
|
+
readonly refs: readonly [tip: string] | readonly [base: string, head: string];
|
|
10
|
+
readonly source: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Outcome = "passed" | "violated" | "undecided";
|
|
14
|
+
|
|
15
|
+
class RangeUnresolved extends Schema.TaggedError<RangeUnresolved>()("RangeUnresolved", {
|
|
16
|
+
message: Schema.String,
|
|
17
|
+
}) {}
|
|
18
|
+
|
|
19
|
+
class GatesUndecided extends Schema.TaggedError<GatesUndecided>()("GatesUndecided", {
|
|
20
|
+
message: Schema.String,
|
|
21
|
+
}) {}
|
|
22
|
+
|
|
23
|
+
const NAME = "checks-lint";
|
|
24
|
+
const USAGE = "usage: lint.ts [<base-ref> <head-ref>]";
|
|
25
|
+
const PULL_REQUEST_EVENT = "pull_request";
|
|
26
|
+
const SHALLOW_HINT = "a CI checkout needs actions/checkout fetch-depth: 0";
|
|
27
|
+
|
|
28
|
+
const decodePullRequestEvent = Schema.decodeUnknownEffect(
|
|
29
|
+
Schema.fromJsonString(
|
|
30
|
+
Schema.Struct({
|
|
31
|
+
pull_request: Schema.Struct({
|
|
32
|
+
number: Schema.Int,
|
|
33
|
+
base: Schema.Struct({ ref: Schema.String }),
|
|
34
|
+
head: Schema.Struct({ sha: Schema.String }),
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const pullRequestEnds = Effect.fn("pullRequestEnds")(function* (eventPath: string) {
|
|
41
|
+
const fs = yield* FileSystem.FileSystem;
|
|
42
|
+
const { pull_request: pullRequest } = yield* fs.readFileString(eventPath).pipe(
|
|
43
|
+
Effect.flatMap(decodePullRequestEvent),
|
|
44
|
+
Effect.mapError(
|
|
45
|
+
(cause) => new RangeUnresolved({ message: `cannot read the pull request from ${eventPath}: ${cause.message}` }),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
return {
|
|
49
|
+
base: `origin/${pullRequest.base.ref}`,
|
|
50
|
+
head: pullRequest.head.sha,
|
|
51
|
+
source: `pull request #${pullRequest.number} into ${pullRequest.base.ref}`,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const decodeManifest = Schema.decodeUnknownEffect(
|
|
56
|
+
Schema.fromJsonString(
|
|
57
|
+
Schema.Struct({
|
|
58
|
+
ciWiring: Schema.optionalKey(Schema.Struct({ defaultBranch: Schema.optionalKey(Schema.NonEmptyString) })),
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const declaredDefaultBranch = Effect.gen(function* () {
|
|
64
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
65
|
+
const manifest = (yield* Path.Path).join(root, "package.json");
|
|
66
|
+
const { ciWiring } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(Effect.flatMap(decodeManifest));
|
|
67
|
+
return ciWiring?.defaultBranch ?? DEFAULT_BRANCH;
|
|
68
|
+
}).pipe(
|
|
69
|
+
Effect.mapError((cause) => new RangeUnresolved({ message: `cannot read ciWiring.defaultBranch: ${cause.message}` })),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const localEnds = git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).pipe(
|
|
73
|
+
Effect.map((ref) => ref.trim()),
|
|
74
|
+
Effect.catchTag("GitFailure", () => declaredDefaultBranch.pipe(Effect.map((branch) => `origin/${branch}`))),
|
|
75
|
+
Effect.map((base) => ({ base, head: "HEAD", source: `HEAD against ${base}` })),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const endsOf = Effect.fn("endsOf")(function* (args: readonly string[]) {
|
|
79
|
+
const [base, head, ...extra] = args;
|
|
80
|
+
if (base !== undefined && head !== undefined && extra.length === 0) {
|
|
81
|
+
return { base, head, source: `${head} against ${base}` };
|
|
82
|
+
}
|
|
83
|
+
if (base !== undefined) return yield* new Usage({ message: USAGE });
|
|
84
|
+
|
|
85
|
+
const event = yield* Config.all({
|
|
86
|
+
name: Config.option(Config.String("GITHUB_EVENT_NAME")),
|
|
87
|
+
path: Config.option(Config.String("GITHUB_EVENT_PATH")),
|
|
88
|
+
}).pipe(Effect.mapError((cause) => new RangeUnresolved({ message: cause.message })));
|
|
89
|
+
if (Option.isSome(event.name) && event.name.value === PULL_REQUEST_EVENT) {
|
|
90
|
+
if (Option.isNone(event.path)) {
|
|
91
|
+
return yield* new RangeUnresolved({ message: `${PULL_REQUEST_EVENT} sets no GITHUB_EVENT_PATH` });
|
|
92
|
+
}
|
|
93
|
+
return yield* pullRequestEnds(event.path.value);
|
|
94
|
+
}
|
|
95
|
+
return yield* localEnds;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const commitOf = (ref: string) =>
|
|
99
|
+
git(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]).pipe(
|
|
100
|
+
Effect.map((sha) => sha.trim()),
|
|
101
|
+
Effect.mapError(
|
|
102
|
+
(cause) => new RangeUnresolved({ message: `${ref} is not a commit in this clone; ${SHALLOW_HINT}: ${cause.message}` }),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// From the base branch's tip, every range gate would charge the head with the commits the base
|
|
107
|
+
// branch gained after the head branched off.
|
|
108
|
+
const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[]) {
|
|
109
|
+
const ends = yield* endsOf(args);
|
|
110
|
+
const head = yield* commitOf(ends.head);
|
|
111
|
+
const base = yield* git(["merge-base", yield* commitOf(ends.base), head]).pipe(
|
|
112
|
+
Effect.map((sha) => sha.trim()),
|
|
113
|
+
Effect.mapError(
|
|
114
|
+
() => new RangeUnresolved({ message: `${ends.base} and ${ends.head} share no commit in this clone; ${SHALLOW_HINT}` }),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
return { refs: base === head ? [head] : [base, head], source: ends.source } satisfies Range;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function describe({ refs }: Range): string {
|
|
121
|
+
return refs.length === 1 ? `tip ${refs[0]}` : `range ${refs[0]}..${refs[1]}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function outcomeOf(exitCode: number): Outcome {
|
|
125
|
+
if (exitCode === 0) return "passed";
|
|
126
|
+
return exitCode === 1 ? "violated" : "undecided";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
|
|
130
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
131
|
+
const script = (yield* Path.Path).join(import.meta.dir, gate.script);
|
|
132
|
+
const program = gate.script.endsWith(".sh") ? "sh" : process.execPath;
|
|
133
|
+
const args = gate.reads === "range" ? [script, ...range.refs] : [script];
|
|
134
|
+
const exitCode = yield* spawner
|
|
135
|
+
.exitCode(ChildProcess.make(program, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }))
|
|
136
|
+
.pipe(
|
|
137
|
+
Effect.catch((cause) =>
|
|
138
|
+
Console.error(`${NAME}: cannot run ${gate.bin}: ${cause.message}`).pipe(Effect.as(ChildProcessSpawner.ExitCode(2))),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
return { gate, outcome: outcomeOf(exitCode) };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const lint = Effect.gen(function* () {
|
|
145
|
+
const range = yield* resolveRange(process.argv.slice(2));
|
|
146
|
+
yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
|
|
147
|
+
|
|
148
|
+
const verdicts = yield* Effect.forEach(KIT_GATES, (gate) => runGate(gate, range));
|
|
149
|
+
const failed = verdicts.filter((verdict) => verdict.outcome !== "passed");
|
|
150
|
+
if (failed.length === 0) {
|
|
151
|
+
yield* Console.log(`${NAME}: ${KIT_GATES.length} gate(s) pass`);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
const summary = `${failed.length} of ${KIT_GATES.length} gate(s) failed: ${failed.map((verdict) => verdict.gate.bin).join(", ")}`;
|
|
155
|
+
if (failed.every((verdict) => verdict.outcome === "undecided")) {
|
|
156
|
+
return yield* new GatesUndecided({ message: summary });
|
|
157
|
+
}
|
|
158
|
+
yield* Console.error(`${NAME}: ${summary}`);
|
|
159
|
+
return false;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (import.meta.main) runMain(NAME, lint);
|
package/scripts/main.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
2
|
+
import { Cause, Console, Effect, Exit, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
const PASSED = 0;
|
|
5
|
+
const VIOLATED = 1;
|
|
6
|
+
const UNDECIDED = 2;
|
|
7
|
+
const INTERRUPTED = 130;
|
|
8
|
+
|
|
9
|
+
export class Usage extends Schema.TaggedError<Usage>()("Usage", {
|
|
10
|
+
message: Schema.String,
|
|
11
|
+
}) {}
|
|
12
|
+
|
|
13
|
+
function statusOf<A, E>(exit: Exit.Exit<A, E>): number {
|
|
14
|
+
if (Exit.isSuccess(exit)) return typeof exit.value === "number" ? exit.value : UNDECIDED;
|
|
15
|
+
return Cause.hasInterruptsOnly(exit.cause) ? INTERRUPTED : UNDECIDED;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function runMain<E extends { readonly message: string }>(
|
|
19
|
+
name: string,
|
|
20
|
+
passes: Effect.Effect<boolean, E, BunServices.BunServices>,
|
|
21
|
+
): void {
|
|
22
|
+
const program = passes.pipe(
|
|
23
|
+
Effect.map((passed) => (passed ? PASSED : VIOLATED)),
|
|
24
|
+
Effect.catch((failure) => Console.error(`${name}: ${failure.message}`).pipe(Effect.as(UNDECIDED))),
|
|
25
|
+
Effect.provide(BunServices.layer),
|
|
26
|
+
);
|
|
27
|
+
BunRuntime.runMain(program, { teardown: (exit, onExit) => onExit(statusOf(exit)) });
|
|
28
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { Console, Effect, FileSystem, Schema } from "effect";
|
|
3
|
+
import { runMain, Usage } from "./main.ts";
|
|
3
4
|
|
|
4
5
|
export type Tally = {
|
|
5
6
|
readonly killed: number;
|
|
@@ -25,42 +26,31 @@ export type Options = {
|
|
|
25
26
|
readonly advisory: boolean;
|
|
26
27
|
};
|
|
27
28
|
|
|
28
|
-
export class ReportError extends
|
|
29
|
+
export class ReportError extends Schema.TaggedError<ReportError>()("ReportError", {
|
|
30
|
+
message: Schema.String,
|
|
31
|
+
}) {}
|
|
29
32
|
|
|
30
33
|
const DETECTED = new Set(["Killed", "Timeout"]);
|
|
31
34
|
const UNDETECTED = new Set(["Survived", "NoCoverage"]);
|
|
32
35
|
const USAGE = "usage: mutation-compare.ts [--advisory] <base-report> <head-report>";
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
throw new ReportError(`mutation-compare: ${path} has no mutants list`);
|
|
52
|
-
}
|
|
53
|
-
const statuses: string[] = [];
|
|
54
|
-
for (const mutant of entry["mutants"]) {
|
|
55
|
-
if (!isRecord(mutant) || typeof mutant["status"] !== "string") {
|
|
56
|
-
throw new ReportError(`mutation-compare: ${path} carries a mutant without a status`);
|
|
57
|
-
}
|
|
58
|
-
statuses.push(mutant["status"]);
|
|
59
|
-
}
|
|
60
|
-
files.set(path, statuses);
|
|
61
|
-
}
|
|
62
|
-
return files;
|
|
63
|
-
}
|
|
37
|
+
const Report = Schema.fromJsonString(
|
|
38
|
+
Schema.Struct({
|
|
39
|
+
files: Schema.Record(
|
|
40
|
+
Schema.String,
|
|
41
|
+
Schema.Struct({ mutants: Schema.Array(Schema.Struct({ status: Schema.String })) }),
|
|
42
|
+
),
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
const decodeReport = Schema.decodeUnknownEffect(Report);
|
|
46
|
+
|
|
47
|
+
export const parseReport = (source: string, text: string): Effect.Effect<Map<string, readonly string[]>, ReportError> =>
|
|
48
|
+
decodeReport(text).pipe(
|
|
49
|
+
Effect.map(
|
|
50
|
+
({ files }) => new Map(Object.entries(files).map(([path, { mutants }]) => [path, mutants.map(({ status }) => status)])),
|
|
51
|
+
),
|
|
52
|
+
Effect.mapError((cause) => new ReportError({ message: `${source} is not a Stryker mutation report: ${cause.message}` })),
|
|
53
|
+
);
|
|
64
54
|
|
|
65
55
|
function tally(statuses: readonly string[]): Tally {
|
|
66
56
|
let killed = 0;
|
|
@@ -136,38 +126,34 @@ export function formatComparison(comparison: Comparison, advisory: boolean): str
|
|
|
136
126
|
return lines.join("\n");
|
|
137
127
|
}
|
|
138
128
|
|
|
139
|
-
export
|
|
129
|
+
export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]): Effect.fn.Return<Options, Usage> {
|
|
140
130
|
let advisory = false;
|
|
141
131
|
const paths: string[] = [];
|
|
142
132
|
for (const arg of argv) {
|
|
143
133
|
if (arg === "--advisory") advisory = true;
|
|
144
|
-
else if (arg.startsWith("--"))
|
|
134
|
+
else if (arg.startsWith("--")) return yield* new Usage({ message: `${USAGE}: unknown flag ${arg}` });
|
|
145
135
|
else paths.push(arg);
|
|
146
136
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
137
|
+
const [basePath, headPath, ...extra] = paths;
|
|
138
|
+
if (basePath === undefined || headPath === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
139
|
+
return { basePath, headPath, advisory };
|
|
140
|
+
});
|
|
150
141
|
|
|
151
|
-
export function
|
|
152
|
-
return comparison.regression
|
|
142
|
+
export function passes(comparison: Comparison, advisory: boolean): boolean {
|
|
143
|
+
return advisory || !comparison.regression;
|
|
153
144
|
}
|
|
154
145
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
}
|
|
146
|
+
const load = Effect.fn("load")(function* (path: string) {
|
|
147
|
+
const fs = yield* FileSystem.FileSystem;
|
|
148
|
+
const text = yield* fs.readFileString(path).pipe(Effect.mapError(() => new ReportError({ message: `cannot read ${path}` })));
|
|
149
|
+
return yield* parseReport(path, text);
|
|
150
|
+
});
|
|
162
151
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
process.exit(2);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
152
|
+
const compare = Effect.gen(function* () {
|
|
153
|
+
const options = yield* parseArgs(process.argv.slice(2));
|
|
154
|
+
const comparison = compareReports(yield* load(options.basePath), yield* load(options.headPath));
|
|
155
|
+
yield* Console.log(formatComparison(comparison, options.advisory));
|
|
156
|
+
return passes(comparison, options.advisory);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (import.meta.main) runMain("mutation-compare", compare);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, Schema } from "effect";
|
|
3
|
+
import { git } from "./git.ts";
|
|
4
|
+
import { runMain, Usage } from "./main.ts";
|
|
5
|
+
|
|
6
|
+
export const SUPPRESSIONS = "oxlint-suppressions.json";
|
|
7
|
+
|
|
8
|
+
export type Suppressions = ReadonlyMap<string, ReadonlyMap<string, number>>;
|
|
9
|
+
|
|
10
|
+
export type Rise =
|
|
11
|
+
| { readonly kind: "rose"; readonly file: string; readonly rule: string; readonly base: number; readonly head: number }
|
|
12
|
+
| { readonly kind: "appeared"; readonly file: string; readonly rule: string; readonly head: number };
|
|
13
|
+
|
|
14
|
+
export type Ratchet = {
|
|
15
|
+
readonly counted: number;
|
|
16
|
+
readonly lowered: number;
|
|
17
|
+
readonly rises: readonly Rise[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class SuppressionsError extends Schema.TaggedError<SuppressionsError>()("SuppressionsError", {
|
|
21
|
+
message: Schema.String,
|
|
22
|
+
}) {}
|
|
23
|
+
|
|
24
|
+
const USAGE = "usage: suppressions-ratchet.ts <ref> | <base-ref> <head-ref>";
|
|
25
|
+
|
|
26
|
+
const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
|
|
27
|
+
const decodeObject = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
|
|
28
|
+
const decodeEntry = Schema.decodeUnknownEffect(Schema.Struct({ count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) }));
|
|
29
|
+
|
|
30
|
+
export const parseSuppressions = Effect.fn("parseSuppressions")(function* (
|
|
31
|
+
text: string,
|
|
32
|
+
where: string,
|
|
33
|
+
): Effect.fn.Return<Suppressions, SuppressionsError> {
|
|
34
|
+
const refuse = (reason: string) => () => new SuppressionsError({ message: `${where} ${reason}` });
|
|
35
|
+
const parsed = yield* parseJson(text).pipe(Effect.mapError(refuse("is not valid JSON")));
|
|
36
|
+
const files = yield* decodeObject(parsed).pipe(Effect.mapError(refuse("is not an object of files")));
|
|
37
|
+
const suppressions = new Map<string, ReadonlyMap<string, number>>();
|
|
38
|
+
for (const [file, rules] of Object.entries(files)) {
|
|
39
|
+
const entries = yield* decodeObject(rules).pipe(Effect.mapError(refuse(`holds ${file} without an object of rules`)));
|
|
40
|
+
const counts = new Map<string, number>();
|
|
41
|
+
for (const [rule, entry] of Object.entries(entries)) {
|
|
42
|
+
const { count } = yield* decodeEntry(entry).pipe(
|
|
43
|
+
Effect.mapError(refuse(`holds ${file} ${rule} without a whole count`)),
|
|
44
|
+
);
|
|
45
|
+
counts.set(rule, count);
|
|
46
|
+
}
|
|
47
|
+
suppressions.set(file, counts);
|
|
48
|
+
}
|
|
49
|
+
return suppressions;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function compareSuppressions(base: Suppressions, head: Suppressions): Ratchet {
|
|
53
|
+
const rises: Rise[] = [];
|
|
54
|
+
let counted = 0;
|
|
55
|
+
for (const [file, rules] of head) {
|
|
56
|
+
for (const [rule, count] of rules) {
|
|
57
|
+
counted += 1;
|
|
58
|
+
const before = base.get(file)?.get(rule);
|
|
59
|
+
if (before === undefined) {
|
|
60
|
+
if (count > 0) rises.push({ kind: "appeared", file, rule, head: count });
|
|
61
|
+
} else if (count > before) {
|
|
62
|
+
rises.push({ kind: "rose", file, rule, base: before, head: count });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let lowered = 0;
|
|
67
|
+
for (const [file, rules] of base) {
|
|
68
|
+
for (const [rule, count] of rules) {
|
|
69
|
+
if ((head.get(file)?.get(rule) ?? 0) < count) lowered += 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
rises.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
73
|
+
return { counted, lowered, rises };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function describeRise(rise: Rise): string {
|
|
77
|
+
const change = rise.kind === "rose" ? `rose from ${rise.base} to ${rise.head}` : `appeared with ${rise.head}`;
|
|
78
|
+
return `${rise.file} ${rise.rule} ${change}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function report({ counted, lowered, rises }: Ratchet): string {
|
|
82
|
+
if (rises.length === 0) {
|
|
83
|
+
return `suppressions-ratchet: no count in ${SUPPRESSIONS} rose or appeared (${counted} at the head, ${lowered} lowered)`;
|
|
84
|
+
}
|
|
85
|
+
return [
|
|
86
|
+
`suppressions-ratchet: ${rises.length} count(s) in ${SUPPRESSIONS} rose or appeared; fix the site instead of suppressing it:`,
|
|
87
|
+
...rises.map((rise) => ` ${describeRise(rise)}`),
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const commitOf = Effect.fn("commitOf")(function* (rev: string) {
|
|
92
|
+
return (yield* git(["rev-parse", "--verify", `${rev}^{commit}`])).trim();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const mergeBase = Effect.fn("mergeBase")(function* (base: string, head: string) {
|
|
96
|
+
const commit = yield* git(["merge-base", base, head]).pipe(
|
|
97
|
+
Effect.mapError(() => new SuppressionsError({ message: `${base} and ${head} share no commit` })),
|
|
98
|
+
);
|
|
99
|
+
return commit.trim();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const suppressionsAt = Effect.fn("suppressionsAt")(function* (commit: string) {
|
|
103
|
+
const blob = (yield* git(["ls-tree", "--object-only", commit, "--", SUPPRESSIONS])).trim();
|
|
104
|
+
if (blob === "") return new Map() satisfies Suppressions;
|
|
105
|
+
return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${SUPPRESSIONS} at ${commit}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const runRange = Effect.fn("runRange")(function* (base: string, head: string) {
|
|
109
|
+
const headCommit = yield* commitOf(head);
|
|
110
|
+
// At the base tip, a count the base branch lowered after the head branched off would read as a rise at the head.
|
|
111
|
+
const branchPoint = yield* mergeBase(yield* commitOf(base), headCommit);
|
|
112
|
+
return compareSuppressions(yield* suppressionsAt(branchPoint), yield* suppressionsAt(headCommit));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const ratchet = Effect.gen(function* () {
|
|
116
|
+
const [first, second, ...extra] = process.argv.slice(2);
|
|
117
|
+
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
118
|
+
|
|
119
|
+
const result = second !== undefined ? yield* runRange(first, second) : yield* runRange(`${first}^`, first);
|
|
120
|
+
|
|
121
|
+
yield* Console.log(report(result));
|
|
122
|
+
return result.rises.length === 0;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (import.meta.main) runMain("suppressions-ratchet", ratchet);
|
package/scripts/test-layout.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { parse } from "@swc/core";
|
|
3
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
4
|
+
import { git } from "./git.ts";
|
|
5
|
+
import { runMain } from "./main.ts";
|
|
6
|
+
import { ENTRY_POINT } from "./gates.ts";
|
|
6
7
|
|
|
7
8
|
export type Violation = {
|
|
8
9
|
readonly file: string;
|
|
@@ -44,11 +45,21 @@ const REQUIRED_TEST_TABLE: Record<string, unknown> = {
|
|
|
44
45
|
};
|
|
45
46
|
export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
|
|
46
47
|
export const LAYOUT_CHECK_BIN = "checks-test-layout";
|
|
48
|
+
const OWN_ENTRY_POINT = `scripts/${ENTRY_POINT.script}`;
|
|
49
|
+
|
|
50
|
+
export class LayoutError extends Schema.TaggedError<LayoutError>()("LayoutError", {
|
|
51
|
+
message: Schema.String,
|
|
52
|
+
}) {}
|
|
47
53
|
|
|
48
54
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
55
|
return typeof value === "object" && value !== null;
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
function runsLayoutCheck(lint: string): boolean {
|
|
59
|
+
if (lint.includes(LAYOUT_CHECK_MARK) || lint.includes(LAYOUT_CHECK_BIN)) return true;
|
|
60
|
+
return lint.split(/[\s|&;()]+/).some((word) => word === ENTRY_POINT.bin || word === OWN_ENTRY_POINT);
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
function startsWithAny(file: string, prefixes: readonly string[]): boolean {
|
|
53
64
|
return prefixes.some((prefix) => file.startsWith(prefix));
|
|
54
65
|
}
|
|
@@ -177,8 +188,11 @@ function outOfProcessUse(node: Record<string, unknown>): string | undefined {
|
|
|
177
188
|
return undefined;
|
|
178
189
|
}
|
|
179
190
|
|
|
180
|
-
export
|
|
181
|
-
const module =
|
|
191
|
+
export const isolationViolations = Effect.fn("isolationViolations")(function* (file: string, source: string) {
|
|
192
|
+
const module = yield* Effect.tryPromise({
|
|
193
|
+
try: () => parse(source, { syntax: "typescript", tsx: file.endsWith(".tsx"), target: "esnext" }),
|
|
194
|
+
catch: (error) => new LayoutError({ message: `cannot parse ${file}: ${String(error)}` }),
|
|
195
|
+
});
|
|
182
196
|
const violations: Violation[] = [];
|
|
183
197
|
const seen = new Set<unknown>();
|
|
184
198
|
|
|
@@ -204,7 +218,7 @@ export function isolationViolations(file: string, source: string): readonly Viol
|
|
|
204
218
|
|
|
205
219
|
visit(module);
|
|
206
220
|
return violations;
|
|
207
|
-
}
|
|
221
|
+
});
|
|
208
222
|
|
|
209
223
|
export function scriptViolations(manifest: unknown): readonly Violation[] {
|
|
210
224
|
const file = "package.json";
|
|
@@ -219,11 +233,11 @@ export function scriptViolations(manifest: unknown): readonly Violation[] {
|
|
|
219
233
|
message: `scripts.test must be exactly "${REQUIRED_TEST_SCRIPT}", found ${JSON.stringify(test ?? null)}`,
|
|
220
234
|
});
|
|
221
235
|
}
|
|
222
|
-
if (typeof lint !== "string" ||
|
|
236
|
+
if (typeof lint !== "string" || !runsLayoutCheck(lint)) {
|
|
223
237
|
violations.push({
|
|
224
238
|
file,
|
|
225
239
|
line: undefined,
|
|
226
|
-
message: `scripts.lint must run the layout check: add "${
|
|
240
|
+
message: `scripts.lint must run the layout check: add "${ENTRY_POINT.bin}"`,
|
|
227
241
|
});
|
|
228
242
|
}
|
|
229
243
|
return violations;
|
|
@@ -255,30 +269,38 @@ export function bunfigViolations(consumer: unknown, preset: unknown): readonly V
|
|
|
255
269
|
return violations;
|
|
256
270
|
}
|
|
257
271
|
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
272
|
+
const workingTreeFiles = Effect.fn("workingTreeFiles")(function* (root: string) {
|
|
273
|
+
const fs = yield* FileSystem.FileSystem;
|
|
274
|
+
const path = yield* Path.Path;
|
|
275
|
+
const listed = yield* git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], root);
|
|
276
|
+
return yield* Effect.filter(listed.split("\0").filter(Boolean), (file) => fs.exists(path.join(root, file)), {
|
|
277
|
+
concurrency: "unbounded",
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const parsedToml = Effect.fn("parsedToml")(function* (file: string) {
|
|
282
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(file);
|
|
283
|
+
return yield* Effect.try({
|
|
284
|
+
try: (): unknown => Bun.TOML.parse(text),
|
|
285
|
+
catch: (error) => new LayoutError({ message: `cannot parse ${file}: ${String(error)}` }),
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
|
|
290
|
+
|
|
291
|
+
const readManifest = Effect.fn("readManifest")(function* (file: string) {
|
|
292
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(file);
|
|
293
|
+
return yield* parseJson(text).pipe(
|
|
294
|
+
Effect.mapError((cause) => new LayoutError({ message: `cannot read ${file} as JSON: ${cause.message}` })),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
277
297
|
|
|
278
298
|
export type Result = { readonly files: number; readonly violations: readonly Violation[] };
|
|
279
299
|
|
|
280
|
-
export
|
|
281
|
-
const
|
|
300
|
+
export const run = Effect.fn("run")(function* (root: string, presetPath: string) {
|
|
301
|
+
const fs = yield* FileSystem.FileSystem;
|
|
302
|
+
const path = yield* Path.Path;
|
|
303
|
+
const files = yield* workingTreeFiles(root);
|
|
282
304
|
const violations: Violation[] = [...placementViolations(files)];
|
|
283
305
|
|
|
284
306
|
const inProcess = files.filter(
|
|
@@ -289,19 +311,18 @@ export async function run(root: string, presetPath: string): Promise<Result> {
|
|
|
289
311
|
!file.startsWith(DATA_DIR),
|
|
290
312
|
);
|
|
291
313
|
for (const file of inProcess) {
|
|
292
|
-
const source =
|
|
293
|
-
violations.push(...isolationViolations(file, source));
|
|
314
|
+
const source = yield* fs.readFileString(path.join(root, file));
|
|
315
|
+
violations.push(...(yield* isolationViolations(file, source)));
|
|
294
316
|
}
|
|
295
317
|
|
|
296
|
-
violations.push(...scriptViolations(
|
|
318
|
+
violations.push(...scriptViolations(yield* readManifest(path.join(root, "package.json"))));
|
|
297
319
|
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
violations.push(...bunfigViolations(consumerBunfig, await parsedToml(presetPath)));
|
|
320
|
+
const bunfig = path.join(root, "bunfig.toml");
|
|
321
|
+
const consumerBunfig = (yield* fs.exists(bunfig)) ? yield* parsedToml(bunfig) : undefined;
|
|
322
|
+
violations.push(...bunfigViolations(consumerBunfig, yield* parsedToml(presetPath)));
|
|
302
323
|
|
|
303
324
|
return { files: files.length, violations };
|
|
304
|
-
}
|
|
325
|
+
});
|
|
305
326
|
|
|
306
327
|
export function report({ files, violations }: Result): string {
|
|
307
328
|
if (violations.length === 0) return `test-layout: ${files} files satisfy the layout`;
|
|
@@ -312,10 +333,12 @@ export function report({ files, violations }: Result): string {
|
|
|
312
333
|
return [`test-layout: ${violations.length} violation(s)`, ...lines].join("\n");
|
|
313
334
|
}
|
|
314
335
|
|
|
315
|
-
|
|
336
|
+
const layout = Effect.gen(function* () {
|
|
337
|
+
const path = yield* Path.Path;
|
|
316
338
|
const root = process.argv[2] ?? process.cwd();
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
339
|
+
const result = yield* run(root, path.join(import.meta.dir, "..", "bunfig.toml"));
|
|
340
|
+
yield* Console.log(report(result));
|
|
341
|
+
return result.violations.length === 0;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (import.meta.main) runMain("test-layout", layout);
|