@avi2dg/checks 0.9.0 → 0.11.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.
@@ -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);