@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/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, KIT_GATES, type KitGate } from "./gates.ts";
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 decodeManifest = Schema.decodeUnknownEffect(
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 declaredDefaultBranch = Effect.gen(function* () {
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(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
- );
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 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
- );
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
- const endsOf = Effect.fn("endsOf")(function* (args: readonly string[]) {
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 range = yield* resolveRange(process.argv.slice(2));
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(KIT_GATES, (gate) => runGate(gate, range));
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}: ${KIT_GATES.length} gate(s) pass`);
168
+ yield* Console.log(`${NAME}: ${gates.length} gate(s) pass`);
152
169
  return true;
153
170
  }
154
- const summary = `${failed.length} of ${KIT_GATES.length} gate(s) failed: ${failed.map((verdict) => verdict.gate.bin).join(", ")}`;
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* (commit: string) {
103
- const blob = (yield* git(["ls-tree", "--object-only", commit, "--", SUPPRESSIONS])).trim();
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 ${commit}`);
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* runRange(`${first}^`, first);
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;
@@ -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 !== REQUIRED_TEST_SCRIPT) {
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 "${REQUIRED_TEST_SCRIPT}", found ${JSON.stringify(test ?? null)}`,
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
+ });
@@ -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);