@avi2dg/checks 0.6.0 → 0.9.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.
@@ -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, parentOrEmptyTree } 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,43 @@ 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* (treeish: string) {
103
+ const blob = (yield* git(["ls-tree", "--object-only", treeish, "--", SUPPRESSIONS])).trim();
104
+ if (blob === "") return new Map() satisfies Suppressions;
105
+ return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${SUPPRESSIONS} at ${treeish}`);
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
+ 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
+
120
+ const ratchet = Effect.gen(function* () {
135
121
  const [first, second, ...extra] = process.argv.slice(2);
136
- if (first === undefined || extra.length > 0) die(USAGE);
122
+ if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
137
123
 
138
- const ratchet = second !== undefined ? runRange(first, second) : runRange(`${first}^`, first);
124
+ const result = second !== undefined ? yield* runRange(first, second) : yield* runTip(first);
139
125
 
140
- console.log(report(ratchet));
141
- process.exit(ratchet.rises.length === 0 ? 0 : 1);
142
- }
126
+ yield* Console.log(report(result));
127
+ return result.rises.length === 0;
128
+ });
129
+
130
+ if (import.meta.main) runMain("suppressions-ratchet", ratchet);
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env bun
2
- import { parseSync } from "@swc/core";
3
- import { readFile } from "node:fs/promises";
4
- import { join } from "node:path";
5
- import { fileURLToPath } from "node:url";
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 function isolationViolations(file: string, source: string): readonly Violation[] {
181
- const module = parseSync(source, { syntax: "typescript", tsx: file.endsWith(".tsx"), target: "esnext" });
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" || (!lint.includes(LAYOUT_CHECK_MARK) && !lint.includes(LAYOUT_CHECK_BIN))) {
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 "${LAYOUT_CHECK_BIN}"`,
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
- async function workingTreeFiles(root: string): Promise<readonly string[]> {
259
- const listed = Bun.spawnSync(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: root });
260
- if (listed.exitCode !== 0) {
261
- throw new Error(`test-layout: git ls-files failed in ${root}: ${listed.stderr.toString()}`);
262
- }
263
- const listedFiles = listed.stdout.toString().split("\0").filter(Boolean);
264
- const present = await Promise.all(listedFiles.map((file) => Bun.file(join(root, file)).exists()));
265
- return listedFiles.filter((_, index) => present[index]);
266
- }
267
-
268
- async function parsedToml(path: string): Promise<unknown> {
269
- const imported: unknown = await import(path);
270
- return isRecord(imported) ? (imported["default"] ?? imported) : imported;
271
- }
272
-
273
- async function readManifest(root: string): Promise<unknown> {
274
- const text = await readFile(join(root, "package.json"), "utf8");
275
- return JSON.parse(text) as unknown;
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 async function run(root: string, presetPath: string): Promise<Result> {
281
- const files = await workingTreeFiles(root);
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 = await readFile(join(root, file), "utf8");
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(await readManifest(root)));
318
+ violations.push(...scriptViolations(yield* readManifest(path.join(root, "package.json"))));
297
319
 
298
- const consumerBunfig = (await Bun.file(join(root, "bunfig.toml")).exists())
299
- ? await parsedToml(join(root, "bunfig.toml"))
300
- : undefined;
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
- if (import.meta.main) {
336
+ const layout = Effect.gen(function* () {
337
+ const path = yield* Path.Path;
316
338
  const root = process.argv[2] ?? process.cwd();
317
- const preset = fileURLToPath(new URL("../bunfig.toml", import.meta.url));
318
- const result = await run(root, preset);
319
- console.log(report(result));
320
- process.exit(result.violations.length === 0 ? 0 : 1);
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);