1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
8import * as Predicate from "effect/Predicate";
9import * as Schema from "effect/Schema";
10import * as Stream from "effect/Stream";
11import { ChildProcess, ChildProcessSpawner } from "effect/process";
12import * as NodeModule from "node:module";
13
14// oxlint is only a transitive dependency (via vite-plus), so its bin placement
15// varies by package manager: pnpm hoists it into the virtual store, while
16// other layouts expose vite-plus's LSP-only wrapper instead. Resolve the real
17// package through vite-plus rather than hardcoding either layout, and do it at
18// module scope so a broken install fails once with a resolution error instead
19// of as an opaque defect in every test.
20const oxlintPackageJsonPath = NodeModule.createRequire(
21 NodeModule.createRequire(import.meta.url).resolve("vite-plus/package.json"),
22).resolve("oxlint/package.json");
23
24class OxlintFixtureFailure extends Data.TaggedError("OxlintFixtureFailure")<{
25 readonly exitCode: number;
26 readonly stdout: string;
27 readonly stderr: string;
28}> {
29 static readonly is = (u: unknown): u is OxlintFixtureFailure =>
30 Predicate.isTagged(u, "OxlintFixtureFailure");
31}
32
33class OxlintFixtureExpectedFailure extends Data.TaggedError("OxlintFixtureExpectedFailure")<{
34 readonly ruleName: string;
35}> {
36 override get message() {
37 return `Expected oxlint to report a failure for rule ${this.ruleName}, but it passed.`;
38 }
39}
40
41const encodeOxlintConfig = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
42
43interface RuleHarness {
44 readonly run: (
45 source: string,
46 ) => Effect.Effect<
47 string,
48 OxlintFixtureFailure | PlatformError.PlatformError | Schema.SchemaError,
49 NodeServices.NodeServices
50 >;
51 readonly runAndExpectFailure: (
52 source: string,
53 ) => Effect.Effect<
54 string,
55 OxlintFixtureExpectedFailure | PlatformError.PlatformError | Schema.SchemaError,
56 NodeServices.NodeServices
57 >;
58 readonly valid: (name: string, source: string) => void;
59 readonly invalid: (name: string, source: string, assertion?: (output: string) => void) => void;
60}
61
62interface RuleHarnessOptions {
63 readonly filename?: string;
64 /** Rule options, as they would appear after the severity in the lint config. */
65 readonly ruleOptions?: ReadonlyArray<unknown>;
66}
67
68const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
69 stream.pipe(
70 Stream.decodeText(),
71 Stream.runFold(
72 () => "",
73 (acc, chunk) => acc + chunk,
74 ),
75 );
76
77const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess.Command) {
78 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
79 const child = yield* spawner.spawn(command);
80
81 const [stdout, stderr, exitCode] = yield* Effect.all(
82 [
83 collectStreamAsString(child.stdout),
84 collectStreamAsString(child.stderr),
85 child.exitCode.pipe(Effect.map(Number)),
86 ],
87 { concurrency: "unbounded" },
88 );
89
90 return { exitCode, stdout, stderr };
91}, Effect.scoped);
92
93export const createOxlintRuleHarness = (
94 ruleName: string,
95 options: RuleHarnessOptions = {},
96): RuleHarness => {
97 const [pluginName, shortRuleName] = ruleName.split("/");
98 const diagnosticRuleName =
99 pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName;
100 const test = it.layer(NodeServices.layer);
101
102 const run: RuleHarness["run"] = Effect.fnUntraced(function* (source: string) {
103 const fs = yield* FileSystem.FileSystem;
104 const path = yield* Path.Path;
105 const fixtureDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-oxlint-" });
106 const configPath = path.join(fixtureDir, ".oxlintrc.json");
107 const sourcePath = path.join(fixtureDir, options.filename ?? "fixture.ts");
108 const repoRoot = path.join(import.meta.dirname, "..", "..");
109 const oxlintBin = path.join(path.dirname(oxlintPackageJsonPath), "bin", "oxlint");
110 const pluginPath = path.join(repoRoot, "oxlint-plugin-t3code", "index.ts");
111
112 yield* fs.writeFileString(
113 configPath,
114 yield* encodeOxlintConfig({
115 jsPlugins: [{ name: "t3code", specifier: pluginPath }],
116 rules: { [ruleName]: ["error", ...(options.ruleOptions ?? [])] },
117 }),
118 );
119 yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
120 yield* fs.writeFileString(sourcePath, source);
121
122 // Run through the current Node binary: oxlint's bin is an extensionless
123 // shebang script, which Windows cannot spawn directly and which would
124 // otherwise pick up whatever node is first on PATH.
125 const output = yield* spawnAndCollectOutput(
126 ChildProcess.make(process.execPath, [oxlintBin, "--config", configPath, sourcePath], {
127 cwd: repoRoot,
128 }),
129 );
130
131 if (output.exitCode !== 0) {
132 return yield* new OxlintFixtureFailure({
133 exitCode: output.exitCode,
134 stdout: output.stdout,
135 stderr: output.stderr,
136 });
137 }
138
139 return `${output.stdout}${output.stderr}`;
140 }, Effect.scoped);
141
142 const runAndExpectFailure: RuleHarness["runAndExpectFailure"] = (source) =>
143 run(source).pipe(
144 Effect.matchEffect({
145 onFailure: (error) =>
146 OxlintFixtureFailure.is(error)
147 ? Effect.succeed(
148 `oxlint fixture failed with exit code ${error.exitCode}\n${error.stdout}\n${error.stderr}`,
149 )
150 : Effect.fail(error),
151 onSuccess: () => Effect.fail(new OxlintFixtureExpectedFailure({ ruleName })),
152 }),
153 );
154
155 return {
156 run,
157 runAndExpectFailure,
158 valid(name, source) {
159 test(name, (it) => {
160 it.effect("passes", () => run(source));
161 });
162 },
163 invalid(name, source, assertion) {
164 test(name, (it) => {
165 it.effect("reports the rule diagnostic", () =>
166 runAndExpectFailure(source).pipe(
167 Effect.tap((output) =>
168 Effect.sync(() => {
169 assert.match(output, new RegExp(diagnosticRuleName));
170 assertion?.(output);
171 }),
172 ),
173 ),
174 );
175 });
176 },
177 };
178};