@avi2dg/checks 0.6.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.
@@ -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);
@@ -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 { readFile } from "node:fs/promises";
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 Error {}
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
- function isRecord(value: unknown): value is Record<string, unknown> {
35
- return typeof value === "object" && value !== null;
36
- }
37
-
38
- export function parseReport(text: string): Map<string, readonly string[]> {
39
- let parsed: unknown;
40
- try {
41
- parsed = JSON.parse(text);
42
- } catch {
43
- throw new ReportError("mutation-compare: report is not valid JSON");
44
- }
45
- if (!isRecord(parsed) || !isRecord(parsed["files"])) {
46
- throw new ReportError("mutation-compare: report has no files table");
47
- }
48
- const files = new Map<string, readonly string[]>();
49
- for (const [path, entry] of Object.entries(parsed["files"])) {
50
- if (!isRecord(entry) || !Array.isArray(entry["mutants"])) {
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 function parseArgs(argv: readonly string[]): Options {
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("--")) throw new ReportError(`${USAGE}: unknown flag ${arg}`);
134
+ else if (arg.startsWith("--")) return yield* new Usage({ message: `${USAGE}: unknown flag ${arg}` });
145
135
  else paths.push(arg);
146
136
  }
147
- if (paths.length !== 2 || paths[0] === undefined || paths[1] === undefined) throw new ReportError(USAGE);
148
- return { basePath: paths[0], headPath: paths[1], advisory };
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 exitFor(comparison: Comparison, advisory: boolean): number {
152
- return comparison.regression && !advisory ? 1 : 0;
142
+ export function passes(comparison: Comparison, advisory: boolean): boolean {
143
+ return advisory || !comparison.regression;
153
144
  }
154
145
 
155
- async function load(path: string): Promise<string> {
156
- try {
157
- return await readFile(path, "utf8");
158
- } catch {
159
- throw new ReportError(`mutation-compare: cannot read ${path}`);
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
- if (import.meta.main) {
164
- try {
165
- const options = parseArgs(process.argv.slice(2));
166
- const comparison = compareReports(parseReport(await load(options.basePath)), parseReport(await load(options.headPath)));
167
- console.log(formatComparison(comparison, options.advisory));
168
- process.exit(exitFor(comparison, options.advisory));
169
- } catch (error) {
170
- console.error(error instanceof ReportError ? error.message : `mutation-compare: ${String(error)}`);
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);
@@ -1,4 +1,8 @@
1
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
+
2
6
  export const SUPPRESSIONS = "oxlint-suppressions.json";
3
7
 
4
8
  export type Suppressions = ReadonlyMap<string, ReadonlyMap<string, number>>;
@@ -13,44 +17,37 @@ export type Ratchet = {
13
17
  readonly rises: readonly Rise[];
14
18
  };
15
19
 
16
- export class SuppressionsError extends Error {}
20
+ export class SuppressionsError extends Schema.TaggedError<SuppressionsError>()("SuppressionsError", {
21
+ message: Schema.String,
22
+ }) {}
17
23
 
18
24
  const USAGE = "usage: suppressions-ratchet.ts <ref> | <base-ref> <head-ref>";
19
25
 
20
- function isRecord(value: unknown): value is Record<string, unknown> {
21
- return typeof value === "object" && value !== null && !Array.isArray(value);
22
- }
23
-
24
- function isCount(value: unknown): value is number {
25
- return typeof value === "number" && Number.isInteger(value) && value >= 0;
26
- }
27
-
28
- export function parseSuppressions(text: string, where: string): Suppressions {
29
- let parsed: unknown;
30
- try {
31
- parsed = JSON.parse(text);
32
- } catch {
33
- throw new SuppressionsError(`suppressions-ratchet: ${where} is not valid JSON`);
34
- }
35
- if (!isRecord(parsed)) {
36
- throw new SuppressionsError(`suppressions-ratchet: ${where} is not an object of files`);
37
- }
38
- const files = new Map<string, ReadonlyMap<string, number>>();
39
- for (const [file, rules] of Object.entries(parsed)) {
40
- if (!isRecord(rules)) {
41
- throw new SuppressionsError(`suppressions-ratchet: ${where} holds ${file} without an object of rules`);
42
- }
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`)));
43
40
  const counts = new Map<string, number>();
44
- for (const [rule, entry] of Object.entries(rules)) {
45
- if (!isRecord(entry) || !isCount(entry["count"])) {
46
- throw new SuppressionsError(`suppressions-ratchet: ${where} holds ${file} ${rule} without a whole count`);
47
- }
48
- counts.set(rule, entry["count"]);
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);
49
46
  }
50
- files.set(file, counts);
47
+ suppressions.set(file, counts);
51
48
  }
52
- return files;
53
- }
49
+ return suppressions;
50
+ });
54
51
 
55
52
  export function compareSuppressions(base: Suppressions, head: Suppressions): Ratchet {
56
53
  const rises: Rise[] = [];
@@ -91,52 +88,38 @@ export function report({ counted, lowered, rises }: Ratchet): string {
91
88
  ].join("\n");
92
89
  }
93
90
 
94
- function die(message: string): never {
95
- console.error(message);
96
- process.exit(2);
97
- }
98
-
99
- function git(...args: readonly string[]): string {
100
- const result = Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" });
101
- if (!result.success) {
102
- die(`suppressions-ratchet: git ${args.join(" ")}: ${result.stderr.toString().trim()}`);
103
- }
104
- return result.stdout.toString();
105
- }
106
-
107
- function commitOf(rev: string): string {
108
- return git("rev-parse", "--verify", `${rev}^{commit}`).trim();
109
- }
110
-
111
- function mergeBase(base: string, head: string): string {
112
- const result = Bun.spawnSync(["git", "merge-base", base, head], { stdout: "pipe", stderr: "pipe" });
113
- if (!result.success) die(`suppressions-ratchet: ${base} and ${head} share no commit`);
114
- return result.stdout.toString().trim();
115
- }
116
-
117
- function suppressionsAt(commit: string): Suppressions {
118
- const blob = git("ls-tree", "--object-only", commit, "--", SUPPRESSIONS).trim();
119
- if (blob === "") return new Map();
120
- try {
121
- return parseSuppressions(git("cat-file", "blob", blob), `${SUPPRESSIONS} at ${commit}`);
122
- } catch (error) {
123
- return die(error instanceof SuppressionsError ? error.message : `suppressions-ratchet: ${String(error)}`);
124
- }
125
- }
126
-
127
- export function runRange(base: string, head: string): Ratchet {
128
- const headCommit = commitOf(head);
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);
129
110
  // At the base tip, a count the base branch lowered after the head branched off would read as a rise at the head.
130
- const branchPoint = mergeBase(commitOf(base), headCommit);
131
- return compareSuppressions(suppressionsAt(branchPoint), suppressionsAt(headCommit));
132
- }
111
+ const branchPoint = yield* mergeBase(yield* commitOf(base), headCommit);
112
+ return compareSuppressions(yield* suppressionsAt(branchPoint), yield* suppressionsAt(headCommit));
113
+ });
133
114
 
134
- if (import.meta.main) {
115
+ const ratchet = Effect.gen(function* () {
135
116
  const [first, second, ...extra] = process.argv.slice(2);
136
- if (first === undefined || extra.length > 0) die(USAGE);
117
+ if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
137
118
 
138
- const ratchet = second !== undefined ? runRange(first, second) : runRange(`${first}^`, first);
119
+ const result = second !== undefined ? yield* runRange(first, second) : yield* runRange(`${first}^`, first);
139
120
 
140
- console.log(report(ratchet));
141
- process.exit(ratchet.rises.length === 0 ? 0 : 1);
142
- }
121
+ yield* Console.log(report(result));
122
+ return result.rises.length === 0;
123
+ });
124
+
125
+ if (import.meta.main) runMain("suppressions-ratchet", ratchet);