@drkmttr/adhere 0.21.2 → 0.24.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/README.md +28 -1552
- package/package.json +10 -7
- package/presets/README.md +9 -1
- package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
- package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
- package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
- package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
- package/skills/adhere/SKILL.md +214 -30
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +49 -16
- package/src/cli.ts +216 -25
- package/src/config.ts +73 -4
- package/src/create/ask.ts +255 -9
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +62 -19
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +625 -0
- package/src/create/trial.ts +663 -0
- package/src/create/wizard.ts +131 -95
- package/src/filters.ts +86 -0
- package/src/init.ts +169 -40
- package/src/install.ts +295 -93
- package/src/instructions.ts +34 -12
- package/src/markdown.ts +21 -2
- package/src/oxlint/catalog.json +7844 -0
- package/src/oxlint/catalog.ts +72 -0
- package/src/oxlint/shared.ts +140 -0
- package/src/readme-guide.ts +431 -0
- package/src/readme.ts +291 -0
- package/src/services/AdhereConfig.ts +36 -3
- package/src/services/AuditCache.ts +176 -169
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +3 -3
- package/src/workflows/audit.ts +40 -7
- package/src/workflows/format.ts +1 -1
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The oxlint side of `adhere rules create`: the exact config a native rule
|
|
3
|
+
* needs, the files a custom JS plugin is written to, a check of that plugin
|
|
4
|
+
* with oxlint's own RuleTester (installed in the project rather than bundled
|
|
5
|
+
* with adhere), and the one operation that sets either up.
|
|
6
|
+
*/
|
|
7
|
+
import { type Agent, run } from "#create/agents.ts";
|
|
8
|
+
import {
|
|
9
|
+
type Shape,
|
|
10
|
+
NativeRevision,
|
|
11
|
+
PluginDraft,
|
|
12
|
+
RuleNotCreated,
|
|
13
|
+
askingFor,
|
|
14
|
+
nativeRevisionPrompt,
|
|
15
|
+
pluginFixPrompt,
|
|
16
|
+
pluginPrompt,
|
|
17
|
+
pluginRevisionPrompt,
|
|
18
|
+
} from "#create/ask.ts";
|
|
19
|
+
import { reviewedInRepository } from "#create/examples.ts";
|
|
20
|
+
import { decided, line, note, outro, spinning, warn } from "#create/look.ts";
|
|
21
|
+
import { trialAndReview, trialStatus } from "#create/trial-review.ts";
|
|
22
|
+
import { cloned } from "#install.ts";
|
|
23
|
+
import { oxlintCatalog, oxlintRule, type OxlintRule } from "#oxlint/catalog.ts";
|
|
24
|
+
import { SHARED_LINT_DIRECTORY, SHARED_LINT_RULES } from "#oxlint/shared.ts";
|
|
25
|
+
import { isSharedRepo, refreshIndexIfShared, refreshReadme, validateReadme } from "#readme.ts";
|
|
26
|
+
import { Effect, FileSystem, Option, Path } from "effect";
|
|
27
|
+
import { Prompt } from "effect/cli";
|
|
28
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
29
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { pathToFileURL } from "node:url";
|
|
34
|
+
import { isDeepStrictEqual } from "node:util";
|
|
35
|
+
|
|
36
|
+
/** One place a rule's example is expected to report. */
|
|
37
|
+
export interface PluginError {
|
|
38
|
+
readonly message: string;
|
|
39
|
+
/** Zero-indexed, as oxlint's RuleTester counts columns. Decoded drafts carry the key with `undefined` when the agent sent `null`. */
|
|
40
|
+
readonly line?: number | undefined;
|
|
41
|
+
readonly column?: number | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Code that breaks a plugin rule, and the diagnostics it must produce. */
|
|
45
|
+
export interface PluginInvalid {
|
|
46
|
+
readonly code: string;
|
|
47
|
+
readonly errors: ReadonlyArray<PluginError>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An oxlint JS plugin rule, its self-contained module, and what shows it works. */
|
|
51
|
+
export interface PluginAnswer {
|
|
52
|
+
readonly plugin: string;
|
|
53
|
+
readonly pluginName: string;
|
|
54
|
+
readonly ruleName: string;
|
|
55
|
+
readonly valid: ReadonlyArray<string>;
|
|
56
|
+
readonly invalid: ReadonlyArray<PluginInvalid>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type PluginCheck =
|
|
60
|
+
| { readonly _tag: "Passed" }
|
|
61
|
+
| { readonly _tag: "Unavailable"; readonly problems: ReadonlyArray<string> }
|
|
62
|
+
| { readonly _tag: "Failed"; readonly problems: ReadonlyArray<string> };
|
|
63
|
+
|
|
64
|
+
export const PLUGIN_DIRECTORY = ".adhere/oxlint";
|
|
65
|
+
|
|
66
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
67
|
+
|
|
68
|
+
/** The plugin part of a native rule id: `eslint` for an unprefixed rule. */
|
|
69
|
+
export const pluginOf = (id: string) => {
|
|
70
|
+
const at = id.indexOf("/");
|
|
71
|
+
return at === -1 ? "eslint" : id.slice(0, at);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
75
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
76
|
+
|
|
77
|
+
const specifierOf = (entry: unknown) =>
|
|
78
|
+
typeof entry === "string"
|
|
79
|
+
? entry
|
|
80
|
+
: isRecord(entry) && typeof entry.specifier === "string"
|
|
81
|
+
? entry.specifier
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
/** Native plugins oxlint runs without being asked; setting `plugins` replaces them. */
|
|
85
|
+
export const BASE_PLUGINS = ["eslint", "typescript", "unicorn", "oxc"];
|
|
86
|
+
|
|
87
|
+
const OFF = new Set<unknown>(["off", "allow", "0", 0]);
|
|
88
|
+
|
|
89
|
+
/** Whether a rule setting is off, whether written `"off"`, `"allow"`, `0`, or with options. */
|
|
90
|
+
const isOff = (setting: unknown) => OFF.has(Array.isArray(setting) ? setting[0] : setting);
|
|
91
|
+
|
|
92
|
+
/** Whether a config's setting already turns `id` on, whatever severity or options it carries. */
|
|
93
|
+
export const nativeOn = (config: unknown, id: string) => {
|
|
94
|
+
const rules = isRecord(config) && isRecord(config.rules) ? config.rules : {};
|
|
95
|
+
return rules[id] !== undefined && !isOff(rules[id]);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** A config whose `plugins` is not a list of names cannot take another plugin without losing it. */
|
|
99
|
+
export const pluginsProblem = (config: unknown) =>
|
|
100
|
+
isRecord(config) &&
|
|
101
|
+
config.plugins !== undefined &&
|
|
102
|
+
!(Array.isArray(config.plugins) && config.plugins.every((entry) => typeof entry === "string"))
|
|
103
|
+
? "plugins is not a list of plugin names"
|
|
104
|
+
: undefined;
|
|
105
|
+
|
|
106
|
+
/** The setting a native rule takes: bare `"error"`, or its exact options after the severity. */
|
|
107
|
+
export const nativeSetting = (options: ReadonlyArray<unknown>) =>
|
|
108
|
+
options.length === 0 ? "error" : ["error", ...options];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The shared lint config with a native rule on with `options`: every other key
|
|
112
|
+
* left as it was. An existing setting on with options of its own is replaced,
|
|
113
|
+
* because the idea needs these; an off tuple keeps its options when the idea
|
|
114
|
+
* needs none. Gives back the same object when nothing needs changing.
|
|
115
|
+
*/
|
|
116
|
+
export const enabledNative = (
|
|
117
|
+
config: unknown,
|
|
118
|
+
id: string,
|
|
119
|
+
options: ReadonlyArray<unknown> = [],
|
|
120
|
+
) => {
|
|
121
|
+
const base = isRecord(config) ? config : {};
|
|
122
|
+
const rules = isRecord(base.rules) ? base.rules : {};
|
|
123
|
+
const rule = oxlintRule(id);
|
|
124
|
+
const plugin = pluginOf(id);
|
|
125
|
+
const plugins = Array.isArray(base.plugins) ? base.plugins : undefined;
|
|
126
|
+
const pluginMissing =
|
|
127
|
+
plugins === undefined
|
|
128
|
+
? !BASE_PLUGINS.includes(plugin)
|
|
129
|
+
: plugins.every((entry) => typeof entry === "string") && !plugins.includes(plugin);
|
|
130
|
+
const typeAwareMissing =
|
|
131
|
+
rule !== undefined &&
|
|
132
|
+
rule.typeAware &&
|
|
133
|
+
!(isRecord(base.options) && base.options.typeAware === true);
|
|
134
|
+
const setting = rules[id];
|
|
135
|
+
const off = setting !== undefined && isOff(setting);
|
|
136
|
+
const on = setting !== undefined && !off;
|
|
137
|
+
const desired = nativeSetting(options);
|
|
138
|
+
const matches = on && (options.length === 0 || isDeepStrictEqual(setting, desired));
|
|
139
|
+
if (isRecord(config) && matches && !pluginMissing && !typeAwareMissing) return base;
|
|
140
|
+
const kept =
|
|
141
|
+
on && options.length === 0
|
|
142
|
+
? setting
|
|
143
|
+
: off && Array.isArray(setting) && options.length === 0
|
|
144
|
+
? ["error", ...setting.slice(1)]
|
|
145
|
+
: desired;
|
|
146
|
+
const next: Record<string, unknown> = { ...base, rules: { ...rules, [id]: kept } };
|
|
147
|
+
if (plugins === undefined) {
|
|
148
|
+
if (!BASE_PLUGINS.includes(plugin)) next.plugins = [...BASE_PLUGINS, plugin];
|
|
149
|
+
} else if (pluginMissing) {
|
|
150
|
+
next.plugins = [...plugins, plugin];
|
|
151
|
+
}
|
|
152
|
+
if (typeAwareMissing) {
|
|
153
|
+
next.options = { ...(isRecord(base.options) ? base.options : {}), typeAware: true };
|
|
154
|
+
}
|
|
155
|
+
return next;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** The shared lint config with a custom plugin registered and its rule on. */
|
|
159
|
+
export const enabledPlugin = (config: unknown, pluginName: string, ruleName: string) => {
|
|
160
|
+
const base = isRecord(config) ? config : {};
|
|
161
|
+
const rules = isRecord(base.rules) ? base.rules : {};
|
|
162
|
+
const plugins = Array.isArray(base.jsPlugins) ? base.jsPlugins : [];
|
|
163
|
+
const specifier = "./plugins/" + pluginName + ".js";
|
|
164
|
+
const registered = plugins.some((entry) => specifierOf(entry) === specifier);
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
jsPlugins: registered ? plugins : [...plugins, { name: pluginName, specifier }],
|
|
168
|
+
rules: { ...rules, [pluginName + "/" + ruleName]: "error" },
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** What to add to a project's own oxlint config to turn a native rule on. */
|
|
173
|
+
export const nativeSnippet = (id: string, options: ReadonlyArray<unknown> = []) =>
|
|
174
|
+
JSON.stringify(enabledNative({}, id, options), null, 2);
|
|
175
|
+
|
|
176
|
+
/** What to add to a project's own oxlint config to register and turn on a plugin rule. */
|
|
177
|
+
export const pluginSnippet = (pluginName: string, ruleName: string) =>
|
|
178
|
+
JSON.stringify(
|
|
179
|
+
{
|
|
180
|
+
jsPlugins: ["./" + PLUGIN_DIRECTORY + "/" + pluginName + ".mjs"],
|
|
181
|
+
rules: { [pluginName + "/" + ruleName]: "error" },
|
|
182
|
+
},
|
|
183
|
+
null,
|
|
184
|
+
2,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
/** The same, as the shared repo's `oxlint/rules.json` holds it. */
|
|
188
|
+
export const sharedSnippet = (pluginName: string, ruleName: string) =>
|
|
189
|
+
JSON.stringify(
|
|
190
|
+
{
|
|
191
|
+
jsPlugins: [{ name: pluginName, specifier: "./plugins/" + pluginName + ".js" }],
|
|
192
|
+
rules: { [pluginName + "/" + ruleName]: "error" },
|
|
193
|
+
},
|
|
194
|
+
null,
|
|
195
|
+
2,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
/** Where a project keeps a custom plugin and its checked-in test; ESM whatever its package says. */
|
|
199
|
+
export const projectFiles = (root: string, pluginName: string) => ({
|
|
200
|
+
plugin: join(root, PLUGIN_DIRECTORY, pluginName + ".mjs"),
|
|
201
|
+
test: join(root, PLUGIN_DIRECTORY, pluginName + ".test.mjs"),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/** Where the shared repo keeps them: beside the lint config that reads them. */
|
|
205
|
+
export const sharedFiles = (repo: string, pluginName: string) => ({
|
|
206
|
+
config: join(repo, SHARED_LINT_RULES),
|
|
207
|
+
plugin: join(repo, SHARED_LINT_DIRECTORY, "plugins", pluginName + ".js"),
|
|
208
|
+
test: join(repo, SHARED_LINT_DIRECTORY, "plugins", pluginName + ".test.js"),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/** A plugin's module and examples as they are checked into a project. */
|
|
212
|
+
export interface CheckInOptions {
|
|
213
|
+
/** The entrypoint the project resolves: oxlint or Vite+. */
|
|
214
|
+
readonly tester: string;
|
|
215
|
+
readonly pluginFile: string;
|
|
216
|
+
readonly ruleName: string;
|
|
217
|
+
readonly valid: ReadonlyArray<string>;
|
|
218
|
+
readonly invalid: ReadonlyArray<PluginInvalid>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The checked-in test: a plain ESM file for Node 22 or newer, so it needs
|
|
223
|
+
* only the oxlint the project already has and no test runner.
|
|
224
|
+
*/
|
|
225
|
+
export const checkInTestSource = ({
|
|
226
|
+
tester,
|
|
227
|
+
pluginFile,
|
|
228
|
+
ruleName,
|
|
229
|
+
valid,
|
|
230
|
+
invalid,
|
|
231
|
+
}: CheckInOptions) =>
|
|
232
|
+
[
|
|
233
|
+
"import { RuleTester } from " + JSON.stringify(tester) + ";",
|
|
234
|
+
"import plugin from " + JSON.stringify("./" + pluginFile) + ";",
|
|
235
|
+
"",
|
|
236
|
+
"RuleTester.describe = (name, fn) => fn();",
|
|
237
|
+
"RuleTester.it = (name, fn) => fn();",
|
|
238
|
+
"",
|
|
239
|
+
'const ruleTester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } });',
|
|
240
|
+
"",
|
|
241
|
+
"ruleTester.run(" +
|
|
242
|
+
JSON.stringify(ruleName) +
|
|
243
|
+
", plugin.rules[" +
|
|
244
|
+
JSON.stringify(ruleName) +
|
|
245
|
+
"], " +
|
|
246
|
+
JSON.stringify({ valid, invalid }, null, 2) +
|
|
247
|
+
");",
|
|
248
|
+
"",
|
|
249
|
+
].join("\n");
|
|
250
|
+
|
|
251
|
+
/** Options for the temporary test verifyPlugin runs before anything is written. */
|
|
252
|
+
export interface TestOptions {
|
|
253
|
+
/** The project's `oxlint/plugins-dev`, as a file URL. */
|
|
254
|
+
readonly tester: string;
|
|
255
|
+
/** The plugin under test, as a file URL. */
|
|
256
|
+
readonly plugin: string;
|
|
257
|
+
readonly ruleName: string;
|
|
258
|
+
readonly valid: ReadonlyArray<string>;
|
|
259
|
+
readonly invalid: ReadonlyArray<PluginInvalid>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The throwaway test: RuleTester running synchronously, so a failure exits non-zero. */
|
|
263
|
+
export const pluginTestSource = ({ tester, plugin, ruleName, valid, invalid }: TestOptions) =>
|
|
264
|
+
[
|
|
265
|
+
"import { RuleTester } from " + JSON.stringify(tester) + ";",
|
|
266
|
+
"const { default: plugin } = await import(" + JSON.stringify(plugin) + ");",
|
|
267
|
+
"RuleTester.describe = (name, fn) => fn();",
|
|
268
|
+
"RuleTester.it = (name, fn) => fn();",
|
|
269
|
+
"const rule = plugin === undefined ? undefined : plugin.rules[" +
|
|
270
|
+
JSON.stringify(ruleName) +
|
|
271
|
+
"];",
|
|
272
|
+
"if (rule === undefined) {",
|
|
273
|
+
' throw new Error("The plugin has no rule " + ' + JSON.stringify(ruleName) + ");",
|
|
274
|
+
"}",
|
|
275
|
+
'const ruleTester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } });',
|
|
276
|
+
"ruleTester.run(" +
|
|
277
|
+
JSON.stringify(ruleName) +
|
|
278
|
+
", rule, " +
|
|
279
|
+
JSON.stringify({ valid, invalid }, null, 2) +
|
|
280
|
+
");",
|
|
281
|
+
"",
|
|
282
|
+
].join("\n");
|
|
283
|
+
|
|
284
|
+
/** The project's oxlint or Vite+, which owns `plugins-dev`; never adhere's own copy. */
|
|
285
|
+
export interface RuleTesterModule {
|
|
286
|
+
readonly specifier: string;
|
|
287
|
+
/** The resolved file, for the temporary test. */
|
|
288
|
+
readonly path: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const TESTER_SPECIFIERS = [
|
|
292
|
+
{ specifier: "oxlint/plugins-dev", package: "oxlint", subpath: "./plugins-dev" },
|
|
293
|
+
{
|
|
294
|
+
specifier: "vite-plus/lint/plugins-dev",
|
|
295
|
+
package: "vite-plus",
|
|
296
|
+
subpath: "./lint/plugins-dev",
|
|
297
|
+
},
|
|
298
|
+
] as const;
|
|
299
|
+
|
|
300
|
+
/** The package.json of a package the consumer has installed, found from its root up. */
|
|
301
|
+
const installedIn = (root: string, name: string): string | undefined => {
|
|
302
|
+
let directory = root;
|
|
303
|
+
for (;;) {
|
|
304
|
+
const found = join(directory, "node_modules", name, "package.json");
|
|
305
|
+
if (existsSync(found)) return found;
|
|
306
|
+
const parent = dirname(directory);
|
|
307
|
+
if (parent === directory) return undefined;
|
|
308
|
+
directory = parent;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/** The runtime file a package's exports map names for one subpath. */
|
|
313
|
+
const exportOf = (packageJson: string, subpath: string): string | undefined => {
|
|
314
|
+
const parsed: unknown = JSON.parse(readFileSync(packageJson, "utf8"));
|
|
315
|
+
if (!isRecord(parsed) || !isRecord(parsed.exports)) return undefined;
|
|
316
|
+
const entry = parsed.exports[subpath];
|
|
317
|
+
const target =
|
|
318
|
+
typeof entry === "string"
|
|
319
|
+
? entry
|
|
320
|
+
: isRecord(entry)
|
|
321
|
+
? (entry.default ?? entry.import ?? entry.require ?? entry.node ?? entry["module-sync"])
|
|
322
|
+
: undefined;
|
|
323
|
+
return typeof target === "string" ? join(dirname(packageJson), target) : undefined;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
export const ruleTesterOf = (root: string): RuleTesterModule | undefined => {
|
|
327
|
+
for (const { specifier, package: name, subpath } of TESTER_SPECIFIERS) {
|
|
328
|
+
const installed = installedIn(root, name);
|
|
329
|
+
if (installed === undefined) continue;
|
|
330
|
+
// Read the consumer's own exports map instead of asking a resolver: Bun's
|
|
331
|
+
// createRequire falls back to its global auto-install cache, compiled or not.
|
|
332
|
+
const path = exportOf(installed, subpath);
|
|
333
|
+
if (path !== undefined && existsSync(path)) return { specifier, path };
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A runtime for the temporary test, since a compiled adhere cannot run a
|
|
340
|
+
* script itself. oxlint's RuleTester needs Node 22 or newer: it refuses Bun
|
|
341
|
+
* outright, so there is no fallback.
|
|
342
|
+
*/
|
|
343
|
+
export interface TestRuntime {
|
|
344
|
+
readonly kind: "node";
|
|
345
|
+
readonly path: string;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export const testRuntime = (): TestRuntime | undefined => {
|
|
349
|
+
const node = Bun.which("node");
|
|
350
|
+
if (node === null) return undefined;
|
|
351
|
+
const version = spawnSync(node, ["--version"], { encoding: "utf8" });
|
|
352
|
+
const major = version.status === 0 ? Number(/^v(\d+)\./.exec(version.stdout ?? "")?.[1]) : NaN;
|
|
353
|
+
return Number.isFinite(major) && major >= 22 ? { kind: "node", path: node } : undefined;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
export const TESTER_PREREQUISITE =
|
|
357
|
+
"This project has no oxlint to test the plugin with: add it (bun add --dev oxlint, or use the Vite+ version) and run this again. adhere does not bundle oxlint.";
|
|
358
|
+
export const RUNTIME_PREREQUISITE =
|
|
359
|
+
"Put Node 22 or newer on PATH to run oxlint's RuleTester; it refuses Bun and older Node, and a compiled adhere cannot run a script by itself.";
|
|
360
|
+
|
|
361
|
+
/** Plugin names oxlint implements natively, so a JS plugin cannot take them. */
|
|
362
|
+
const NATIVE_PLUGINS = new Set([
|
|
363
|
+
"eslint",
|
|
364
|
+
"import",
|
|
365
|
+
"jest",
|
|
366
|
+
"jsdoc",
|
|
367
|
+
"jsx-a11y",
|
|
368
|
+
"nextjs",
|
|
369
|
+
"node",
|
|
370
|
+
"oxc",
|
|
371
|
+
"promise",
|
|
372
|
+
"react",
|
|
373
|
+
"react-perf",
|
|
374
|
+
"typescript",
|
|
375
|
+
"unicorn",
|
|
376
|
+
"vitest",
|
|
377
|
+
"vue",
|
|
378
|
+
...oxlintCatalog.rules.map((rule) => pluginOf(rule.id)),
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
/** The imports in a plugin's source, scanned rather than matched against its text. */
|
|
382
|
+
const importsIn = (source: string) => {
|
|
383
|
+
try {
|
|
384
|
+
return new Bun.Transpiler({ loader: "js" }).scanImports(source);
|
|
385
|
+
} catch {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/** What keeps a generated plugin from being written or run, apart from its own tests. */
|
|
391
|
+
export const pluginProblems = (answer: PluginAnswer): ReadonlyArray<string> => [
|
|
392
|
+
...(NAME.test(answer.pluginName)
|
|
393
|
+
? []
|
|
394
|
+
: ["pluginName must be lowercase kebab case, starting with a letter."]),
|
|
395
|
+
...(NAME.test(answer.ruleName)
|
|
396
|
+
? []
|
|
397
|
+
: ["ruleName must be lowercase kebab case, starting with a letter."]),
|
|
398
|
+
...(NATIVE_PLUGINS.has(answer.pluginName)
|
|
399
|
+
? [
|
|
400
|
+
"pluginName " +
|
|
401
|
+
answer.pluginName +
|
|
402
|
+
" clashes with a plugin oxlint has natively; choose another.",
|
|
403
|
+
]
|
|
404
|
+
: []),
|
|
405
|
+
...(answer.plugin.includes("export default")
|
|
406
|
+
? []
|
|
407
|
+
: ["The plugin must default-export its plugin object: export default { meta, rules }."]),
|
|
408
|
+
...(importsIn(answer.plugin).length > 0
|
|
409
|
+
? ["The plugin must be plain ESM with no imports; oxlint loads it as it is."]
|
|
410
|
+
: []),
|
|
411
|
+
...(answer.valid.length === 0 ? ["Give at least one valid example."] : []),
|
|
412
|
+
...(answer.invalid.length === 0 ? ["Give at least one invalid example."] : []),
|
|
413
|
+
...answer.invalid.flatMap((example, index) =>
|
|
414
|
+
example.errors.length === 0
|
|
415
|
+
? ["Invalid example " + (index + 1) + " has no expected diagnostic."]
|
|
416
|
+
: [],
|
|
417
|
+
),
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
const tail = (text: string) => text.trim().slice(-4000);
|
|
421
|
+
|
|
422
|
+
const failed = (cause: unknown) => (cause instanceof Error ? cause : new Error(String(cause)));
|
|
423
|
+
|
|
424
|
+
const runFile = (runtime: TestRuntime, file: string, cwd: string) => {
|
|
425
|
+
const { promise, resolve } = Promise.withResolvers<{
|
|
426
|
+
readonly code: number;
|
|
427
|
+
readonly output: string;
|
|
428
|
+
}>();
|
|
429
|
+
const child = spawn(runtime.path, [file], {
|
|
430
|
+
cwd,
|
|
431
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
432
|
+
});
|
|
433
|
+
let output = "";
|
|
434
|
+
const timer = setTimeout(() => {
|
|
435
|
+
child.kill("SIGKILL");
|
|
436
|
+
resolve({ code: 1, output: output + "\nTimed out after 60 seconds." });
|
|
437
|
+
}, 60_000);
|
|
438
|
+
child.stdout.on("data", (chunk) => {
|
|
439
|
+
output += chunk;
|
|
440
|
+
});
|
|
441
|
+
child.stderr.on("data", (chunk) => {
|
|
442
|
+
output += chunk;
|
|
443
|
+
});
|
|
444
|
+
child.on("error", (problem) => {
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
resolve({ code: 1, output: problem.message });
|
|
447
|
+
});
|
|
448
|
+
child.on("close", (code) => {
|
|
449
|
+
clearTimeout(timer);
|
|
450
|
+
resolve({ code: code ?? 1, output });
|
|
451
|
+
});
|
|
452
|
+
return promise;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Runs a generated plugin against its examples with the project's own oxlint.
|
|
457
|
+
* Nothing is written to the project: the module and test live in a temporary
|
|
458
|
+
* directory until the caller decides to keep them.
|
|
459
|
+
*/
|
|
460
|
+
export const verifyPlugin = (answer: PluginAnswer, root: string) =>
|
|
461
|
+
Effect.scoped(
|
|
462
|
+
Effect.gen(function* () {
|
|
463
|
+
const problems = pluginProblems(answer);
|
|
464
|
+
if (problems.length > 0) return { _tag: "Failed", problems } as const;
|
|
465
|
+
const tester = ruleTesterOf(root);
|
|
466
|
+
if (tester === undefined) {
|
|
467
|
+
return { _tag: "Unavailable", problems: [TESTER_PREREQUISITE] } as const;
|
|
468
|
+
}
|
|
469
|
+
const runtime = testRuntime();
|
|
470
|
+
if (runtime === undefined) {
|
|
471
|
+
return { _tag: "Unavailable", problems: [RUNTIME_PREREQUISITE] } as const;
|
|
472
|
+
}
|
|
473
|
+
const directory = yield* Effect.acquireRelease(
|
|
474
|
+
Effect.tryPromise({
|
|
475
|
+
try: () => mkdtemp(join(tmpdir(), "adhere-oxlint-")),
|
|
476
|
+
catch: failed,
|
|
477
|
+
}),
|
|
478
|
+
(path) => Effect.promise(() => rm(path, { recursive: true, force: true })),
|
|
479
|
+
);
|
|
480
|
+
const pluginFile = join(directory, "plugin.mjs");
|
|
481
|
+
const testFile = join(directory, "test.mjs");
|
|
482
|
+
yield* Effect.tryPromise({
|
|
483
|
+
try: () => writeFile(pluginFile, answer.plugin),
|
|
484
|
+
catch: failed,
|
|
485
|
+
});
|
|
486
|
+
yield* Effect.tryPromise({
|
|
487
|
+
try: () =>
|
|
488
|
+
writeFile(
|
|
489
|
+
testFile,
|
|
490
|
+
pluginTestSource({
|
|
491
|
+
tester: pathToFileURL(tester.path).href,
|
|
492
|
+
plugin: pathToFileURL(pluginFile).href,
|
|
493
|
+
ruleName: answer.ruleName,
|
|
494
|
+
valid: answer.valid,
|
|
495
|
+
invalid: answer.invalid,
|
|
496
|
+
}),
|
|
497
|
+
),
|
|
498
|
+
catch: failed,
|
|
499
|
+
});
|
|
500
|
+
const result = yield* Effect.tryPromise({
|
|
501
|
+
try: () => runFile(runtime, testFile, root),
|
|
502
|
+
catch: failed,
|
|
503
|
+
});
|
|
504
|
+
return result.code === 0
|
|
505
|
+
? ({ _tag: "Passed" } as const)
|
|
506
|
+
: ({
|
|
507
|
+
_tag: "Failed",
|
|
508
|
+
problems: ["The RuleTester run failed:\n" + tail(result.output)],
|
|
509
|
+
} as const);
|
|
510
|
+
}).pipe(
|
|
511
|
+
Effect.catch((cause) =>
|
|
512
|
+
Effect.succeed({
|
|
513
|
+
_tag: "Failed",
|
|
514
|
+
problems: ["The plugin could not be checked: " + tail(failed(cause).message)],
|
|
515
|
+
} as const),
|
|
516
|
+
),
|
|
517
|
+
),
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
/** What the wizard knows when it hands the oxlint route over. */
|
|
521
|
+
export interface OxlintWork {
|
|
522
|
+
readonly agent: Agent;
|
|
523
|
+
readonly idea: string;
|
|
524
|
+
readonly shape: Shape;
|
|
525
|
+
readonly root: string;
|
|
526
|
+
readonly shared: string | undefined;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const RULE_ID = /^[a-z0-9]+(-[a-z0-9]+)*(\/[a-z0-9]+(-[a-z0-9]+)*)+$/;
|
|
530
|
+
|
|
531
|
+
const git = (cwd: string, args: ReadonlyArray<string>) =>
|
|
532
|
+
Effect.tryPromise({
|
|
533
|
+
try: () => run("git", args, cwd, ""),
|
|
534
|
+
catch: (cause) =>
|
|
535
|
+
new RuleNotCreated({
|
|
536
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
537
|
+
}),
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
/** Where something that is not an adhere rule is set up: printed here, or written to the shared repo. */
|
|
541
|
+
const chooseWhere = (
|
|
542
|
+
shared: string | undefined,
|
|
543
|
+
suggested: Shape["scope"],
|
|
544
|
+
projectDescription: string,
|
|
545
|
+
) =>
|
|
546
|
+
Effect.gen(function* () {
|
|
547
|
+
if (shared === undefined) {
|
|
548
|
+
if (suggested === "org") {
|
|
549
|
+
yield* warn(
|
|
550
|
+
"The organization's shared rules repo is not set up, so this stays in this project.\nRun adhere rules create --setup to name the shared repo.",
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
return "project" as const;
|
|
554
|
+
}
|
|
555
|
+
const choices = [
|
|
556
|
+
{ title: "This project", value: "project" as const, description: projectDescription },
|
|
557
|
+
{ title: "The organization", value: "org" as const, description: shared },
|
|
558
|
+
];
|
|
559
|
+
return yield* Prompt.run(
|
|
560
|
+
Prompt.Select({
|
|
561
|
+
message: "Where should it be set up?",
|
|
562
|
+
choices: suggested === "org" ? choices.toReversed() : choices,
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
/** The plugin and the examples that judge it, as the user approves or runs them. */
|
|
568
|
+
const showPlugin = (draft: PluginDraft) =>
|
|
569
|
+
Effect.gen(function* () {
|
|
570
|
+
yield* line("");
|
|
571
|
+
yield* line(draft.pluginName + "/" + draft.ruleName + ":");
|
|
572
|
+
yield* line("");
|
|
573
|
+
yield* line(draft.plugin);
|
|
574
|
+
yield* line("");
|
|
575
|
+
yield* note("Valid examples:");
|
|
576
|
+
for (const code of draft.valid) yield* line(code);
|
|
577
|
+
yield* note("Invalid examples:");
|
|
578
|
+
for (const example of draft.invalid) {
|
|
579
|
+
yield* line(example.code);
|
|
580
|
+
yield* note("expects: " + JSON.stringify(example.errors));
|
|
581
|
+
}
|
|
582
|
+
yield* line("");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Shows the generated plugin and fixtures, runs them with the project's own
|
|
587
|
+
* oxlint once the user approves them, and lets the agent repair them once.
|
|
588
|
+
* Nothing runs before approval.
|
|
589
|
+
*/
|
|
590
|
+
const approvedPlugin = (agent: Agent, root: string, prompt: string, first: PluginDraft) =>
|
|
591
|
+
Effect.gen(function* () {
|
|
592
|
+
let draft = first;
|
|
593
|
+
for (let round = 0; round < 2; round++) {
|
|
594
|
+
yield* showPlugin(draft);
|
|
595
|
+
const approve = yield* Prompt.run(
|
|
596
|
+
Prompt.Confirm({
|
|
597
|
+
message:
|
|
598
|
+
round === 0
|
|
599
|
+
? "Run this generated plugin against these examples?"
|
|
600
|
+
: "Run the repaired plugin against these examples?",
|
|
601
|
+
initial: true,
|
|
602
|
+
}),
|
|
603
|
+
);
|
|
604
|
+
if (!approve) return { _tag: "Declined" } as const;
|
|
605
|
+
const check = yield* verifyPlugin(draft, root);
|
|
606
|
+
if (check._tag === "Passed") return { _tag: "Approved", draft } as const;
|
|
607
|
+
for (const problem of check.problems) yield* warn(problem);
|
|
608
|
+
if (check._tag === "Unavailable") {
|
|
609
|
+
return { _tag: "Unavailable", problems: check.problems } as const;
|
|
610
|
+
}
|
|
611
|
+
if (round === 1) {
|
|
612
|
+
return { _tag: "Failed", problems: check.problems } as const;
|
|
613
|
+
}
|
|
614
|
+
draft = yield* askingFor(
|
|
615
|
+
agent,
|
|
616
|
+
"to fix the oxlint plugin",
|
|
617
|
+
root,
|
|
618
|
+
pluginFixPrompt(prompt, draft, check.problems),
|
|
619
|
+
PluginDraft,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
return { _tag: "Failed", problems: ["The plugin did not pass its examples."] } as const;
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
/** Reads the shared lint config, refusing to overwrite one that is not valid JSON. */
|
|
626
|
+
const readSharedConfig = (file: string) =>
|
|
627
|
+
Effect.gen(function* () {
|
|
628
|
+
const fs = yield* FileSystem.FileSystem;
|
|
629
|
+
if (!(yield* fs.exists(file))) return { _tag: "Ok", config: {} } as const;
|
|
630
|
+
const text = yield* fs.readFileString(file);
|
|
631
|
+
const parsed = yield* Effect.try({
|
|
632
|
+
try: () => JSON.parse(text) as unknown,
|
|
633
|
+
catch: failed,
|
|
634
|
+
}).pipe(Effect.option);
|
|
635
|
+
return Option.isSome(parsed) && isRecord(parsed.value)
|
|
636
|
+
? ({ _tag: "Ok", config: parsed.value } as const)
|
|
637
|
+
: ({ _tag: "Invalid" } as const);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The shared config with a native rule on, or why it cannot be merged or is
|
|
642
|
+
* already on. Writes nothing.
|
|
643
|
+
*/
|
|
644
|
+
const nativeSettingsFor = (file: string, id: string, options: ReadonlyArray<unknown>) =>
|
|
645
|
+
Effect.gen(function* () {
|
|
646
|
+
const read = yield* readSharedConfig(file);
|
|
647
|
+
if (read._tag === "Invalid") {
|
|
648
|
+
yield* warn(file + " is not valid JSON; left it unchanged.");
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
const plugins = pluginsProblem(read.config);
|
|
652
|
+
if (plugins !== undefined) {
|
|
653
|
+
yield* warn(file + ": " + plugins + "; left it unchanged.");
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
const next = enabledNative(read.config, id, options);
|
|
657
|
+
if (next === read.config) return { _tag: "Already" } as const;
|
|
658
|
+
const before = isRecord(read.config.rules) ? read.config.rules[id] : undefined;
|
|
659
|
+
const after = isRecord(next.rules) ? next.rules[id] : undefined;
|
|
660
|
+
if (before !== undefined && !isDeepStrictEqual(before, after)) {
|
|
661
|
+
yield* warn(
|
|
662
|
+
"Replacing the existing setting " +
|
|
663
|
+
JSON.stringify(before) +
|
|
664
|
+
" with " +
|
|
665
|
+
JSON.stringify(after) +
|
|
666
|
+
".",
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
return { _tag: "Ready", config: next } as const;
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
/** A native oxlint rule already checks this: print its settings, or enable it in the shared repo. */
|
|
673
|
+
const createBuiltin = (
|
|
674
|
+
agent: Agent,
|
|
675
|
+
idea: string,
|
|
676
|
+
rule: OxlintRule,
|
|
677
|
+
shape: Shape,
|
|
678
|
+
root: string,
|
|
679
|
+
shared: string | undefined,
|
|
680
|
+
) =>
|
|
681
|
+
Effect.gen(function* () {
|
|
682
|
+
const fs = yield* FileSystem.FileSystem;
|
|
683
|
+
const path = yield* Path.Path;
|
|
684
|
+
const plugin = pluginOf(rule.id);
|
|
685
|
+
yield* decided("oxlint " + oxlintCatalog.version + ": " + rule.id, rule.summary);
|
|
686
|
+
yield* note(
|
|
687
|
+
"Plugin: " +
|
|
688
|
+
plugin +
|
|
689
|
+
(BASE_PLUGINS.includes(plugin)
|
|
690
|
+
? " (on by default)."
|
|
691
|
+
: " (off by default; the snippet enables it)."),
|
|
692
|
+
);
|
|
693
|
+
yield* note(
|
|
694
|
+
"Type-aware: " +
|
|
695
|
+
(rule.typeAware
|
|
696
|
+
? "yes; set options.typeAware: true (or pass --type-aware) and install oxlint-tsgolint."
|
|
697
|
+
: "no."),
|
|
698
|
+
);
|
|
699
|
+
yield* note("Docs: " + rule.docsUrl);
|
|
700
|
+
yield* note("Setting: " + JSON.stringify(nativeSetting(shape.linterOptions)));
|
|
701
|
+
|
|
702
|
+
const outcome = yield* trialAndReview({
|
|
703
|
+
agent,
|
|
704
|
+
root,
|
|
705
|
+
id: rule.id,
|
|
706
|
+
draft: shape.linterOptions,
|
|
707
|
+
toDraft: (options) => ({
|
|
708
|
+
kind: "native",
|
|
709
|
+
id: rule.id,
|
|
710
|
+
options,
|
|
711
|
+
config: enabledNative({}, rule.id, options),
|
|
712
|
+
}),
|
|
713
|
+
approve: (options) => Effect.succeed({ _tag: "Approved", draft: options } as const),
|
|
714
|
+
revise: (options, evidence) =>
|
|
715
|
+
Effect.gen(function* () {
|
|
716
|
+
const revised = yield* askingFor(
|
|
717
|
+
agent,
|
|
718
|
+
"to revise the oxlint settings",
|
|
719
|
+
root,
|
|
720
|
+
nativeRevisionPrompt(idea, rule.id, options, evidence),
|
|
721
|
+
NativeRevision,
|
|
722
|
+
);
|
|
723
|
+
return revised.options;
|
|
724
|
+
}),
|
|
725
|
+
show: (options) => note("Setting: " + JSON.stringify(nativeSetting(options))),
|
|
726
|
+
});
|
|
727
|
+
if (outcome._tag === "Cancelled") return yield* outro("Nothing written.");
|
|
728
|
+
const options = outcome.draft;
|
|
729
|
+
|
|
730
|
+
const sharedHere = yield* isSharedRepo(root);
|
|
731
|
+
const scope = yield* chooseWhere(
|
|
732
|
+
shared,
|
|
733
|
+
shape.scope,
|
|
734
|
+
sharedHere ? "Write this shared repo's " + SHARED_LINT_RULES : "Print the config snippet",
|
|
735
|
+
);
|
|
736
|
+
if (scope === "project" && sharedHere) {
|
|
737
|
+
const write = yield* Prompt.run(
|
|
738
|
+
Prompt.Confirm({
|
|
739
|
+
message: "Enable " + rule.id + " in this repo's " + SHARED_LINT_RULES + "?",
|
|
740
|
+
initial: true,
|
|
741
|
+
}),
|
|
742
|
+
);
|
|
743
|
+
if (!write) return yield* outro("Nothing written.");
|
|
744
|
+
yield* validateReadme(root);
|
|
745
|
+
const file = path.join(root, SHARED_LINT_RULES);
|
|
746
|
+
const settings = yield* nativeSettingsFor(file, rule.id, options);
|
|
747
|
+
if (settings === undefined) return yield* outro("Nothing written.");
|
|
748
|
+
if (settings._tag === "Already") {
|
|
749
|
+
return yield* outro(rule.id + " is already enabled in this repo.");
|
|
750
|
+
}
|
|
751
|
+
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
|
|
752
|
+
yield* fs.writeFileString(file, JSON.stringify(settings.config, null, 2) + "\n");
|
|
753
|
+
const refreshed = yield* refreshReadme(root);
|
|
754
|
+
return yield* outro(
|
|
755
|
+
"Updated " +
|
|
756
|
+
SHARED_LINT_RULES +
|
|
757
|
+
" in this repo.\n" +
|
|
758
|
+
trialStatus(outcome) +
|
|
759
|
+
"\n" +
|
|
760
|
+
refreshed.message,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if (scope === "project") {
|
|
764
|
+
yield* line("");
|
|
765
|
+
yield* line("Add this to the project's oxlint config:");
|
|
766
|
+
yield* line(nativeSnippet(rule.id, options));
|
|
767
|
+
return yield* outro(
|
|
768
|
+
"oxlint " +
|
|
769
|
+
oxlintCatalog.version +
|
|
770
|
+
" provides this rule. Apply the snippet with a compatible oxlint version; no adhere rule was created.\n" +
|
|
771
|
+
trialStatus(outcome),
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
if (shared === undefined) return yield* outro("Nothing written.");
|
|
775
|
+
const write = yield* Prompt.run(
|
|
776
|
+
Prompt.Confirm({
|
|
777
|
+
message: "Enable " + rule.id + " in " + shared + "/" + SHARED_LINT_RULES + " and push?",
|
|
778
|
+
initial: true,
|
|
779
|
+
}),
|
|
780
|
+
);
|
|
781
|
+
if (!write) return yield* outro("Nothing written.");
|
|
782
|
+
const repo = yield* spinning("Cloning " + shared, cloned({ repo: shared, rule: "" }));
|
|
783
|
+
const file = path.join(repo, SHARED_LINT_RULES);
|
|
784
|
+
const settings = yield* nativeSettingsFor(file, rule.id, options);
|
|
785
|
+
if (settings === undefined) return yield* outro("Nothing written.");
|
|
786
|
+
if (settings._tag === "Already") {
|
|
787
|
+
return yield* outro(rule.id + " is already enabled in " + shared + ".");
|
|
788
|
+
}
|
|
789
|
+
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
|
|
790
|
+
yield* fs.writeFileString(file, JSON.stringify(settings.config, null, 2) + "\n");
|
|
791
|
+
const indexed = yield* refreshIndexIfShared(repo);
|
|
792
|
+
const branch = "adhere/oxlint-" + rule.id.replaceAll("/", "-");
|
|
793
|
+
yield* git(repo, ["switch", "--quiet", "--create", branch]);
|
|
794
|
+
const staged = [path.relative(repo, file)];
|
|
795
|
+
if (indexed) staged.push("README.md");
|
|
796
|
+
yield* git(repo, ["add", "--", ...staged]);
|
|
797
|
+
yield* git(repo, ["commit", "--quiet", "--message", "Enable oxlint rule " + rule.id]);
|
|
798
|
+
yield* git(repo, ["push", "--quiet", "origin", branch]);
|
|
799
|
+
yield* outro(
|
|
800
|
+
[
|
|
801
|
+
"Pushed " + branch + " to " + shared + ".",
|
|
802
|
+
trialStatus(outcome),
|
|
803
|
+
"Open the pull request: https://github.com/" + shared + "/compare/" + branch + "?expand=1",
|
|
804
|
+
].join("\n"),
|
|
805
|
+
);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
/** A custom oxlint JS plugin: generated, approved, run against its examples, then written. */
|
|
809
|
+
const createPlugin = (
|
|
810
|
+
agent: Agent,
|
|
811
|
+
idea: string,
|
|
812
|
+
shape: Shape,
|
|
813
|
+
root: string,
|
|
814
|
+
shared: string | undefined,
|
|
815
|
+
) =>
|
|
816
|
+
Effect.gen(function* () {
|
|
817
|
+
const fs = yield* FileSystem.FileSystem;
|
|
818
|
+
const path = yield* Path.Path;
|
|
819
|
+
const id = shape.names.find((name) => RULE_ID.test(name)) ?? "oxlint/plugin";
|
|
820
|
+
const sharedHere = yield* isSharedRepo(root);
|
|
821
|
+
const scope = yield* chooseWhere(
|
|
822
|
+
shared,
|
|
823
|
+
shape.scope,
|
|
824
|
+
sharedHere
|
|
825
|
+
? "Write this shared repo's " + SHARED_LINT_DIRECTORY + "/plugins/"
|
|
826
|
+
: ".adhere/oxlint/",
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
// Examples come from the project the user is in, not from the shared clone.
|
|
830
|
+
const reviewed = yield* reviewedInRepository(agent, root, idea, id);
|
|
831
|
+
const prompt = pluginPrompt(idea, id, reviewed);
|
|
832
|
+
const first = yield* askingFor(agent, "to write the oxlint plugin", root, prompt, PluginDraft);
|
|
833
|
+
const outcome = yield* trialAndReview({
|
|
834
|
+
agent,
|
|
835
|
+
root,
|
|
836
|
+
id: first.pluginName + "/" + first.ruleName,
|
|
837
|
+
draft: first,
|
|
838
|
+
toDraft: (draft) => ({
|
|
839
|
+
kind: "plugin",
|
|
840
|
+
pluginName: draft.pluginName,
|
|
841
|
+
ruleName: draft.ruleName,
|
|
842
|
+
plugin: draft.plugin,
|
|
843
|
+
config: enabledPlugin({}, draft.pluginName, draft.ruleName),
|
|
844
|
+
}),
|
|
845
|
+
approve: (draft) =>
|
|
846
|
+
approvedPlugin(agent, root, prompt, draft).pipe(
|
|
847
|
+
Effect.map((approval) =>
|
|
848
|
+
approval._tag === "Unavailable"
|
|
849
|
+
? ({
|
|
850
|
+
_tag: "Failed",
|
|
851
|
+
problems: approval.problems,
|
|
852
|
+
reason: "oxlint's RuleTester could not run here.",
|
|
853
|
+
} as const)
|
|
854
|
+
: approval,
|
|
855
|
+
),
|
|
856
|
+
),
|
|
857
|
+
revise: (draft, evidence) =>
|
|
858
|
+
askingFor(
|
|
859
|
+
agent,
|
|
860
|
+
"to revise the oxlint plugin",
|
|
861
|
+
root,
|
|
862
|
+
pluginRevisionPrompt(idea, draft.pluginName + "/" + draft.ruleName, draft, evidence),
|
|
863
|
+
PluginDraft,
|
|
864
|
+
),
|
|
865
|
+
show: showPlugin,
|
|
866
|
+
});
|
|
867
|
+
if (outcome._tag === "Cancelled") return yield* outro("Nothing written.");
|
|
868
|
+
const draft = outcome.draft;
|
|
869
|
+
const tester = ruleTesterOf(root);
|
|
870
|
+
if (tester === undefined) {
|
|
871
|
+
return yield* outro("Nothing written: this project has no oxlint to run the test with.");
|
|
872
|
+
}
|
|
873
|
+
const extension = scope === "org" || sharedHere ? ".js" : ".mjs";
|
|
874
|
+
const test = checkInTestSource({
|
|
875
|
+
tester: tester.specifier,
|
|
876
|
+
pluginFile: draft.pluginName + extension,
|
|
877
|
+
ruleName: draft.ruleName,
|
|
878
|
+
valid: draft.valid,
|
|
879
|
+
invalid: draft.invalid,
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
if (scope === "project" && sharedHere) {
|
|
883
|
+
const files = sharedFiles(root, draft.pluginName);
|
|
884
|
+
const write = yield* Prompt.run(
|
|
885
|
+
Prompt.Confirm({
|
|
886
|
+
message:
|
|
887
|
+
"Write " +
|
|
888
|
+
path.relative(root, files.plugin) +
|
|
889
|
+
" and " +
|
|
890
|
+
path.relative(root, files.test) +
|
|
891
|
+
", and enable it in " +
|
|
892
|
+
SHARED_LINT_RULES +
|
|
893
|
+
"?",
|
|
894
|
+
initial: true,
|
|
895
|
+
}),
|
|
896
|
+
);
|
|
897
|
+
if (!write) return yield* outro("Nothing written.");
|
|
898
|
+
yield* validateReadme(root);
|
|
899
|
+
const config = yield* readSharedConfig(files.config);
|
|
900
|
+
if (config._tag === "Invalid") {
|
|
901
|
+
yield* warn(files.config + " is not valid JSON; left it unchanged.");
|
|
902
|
+
return yield* outro("Nothing written.");
|
|
903
|
+
}
|
|
904
|
+
yield* fs.makeDirectory(path.dirname(files.plugin), { recursive: true });
|
|
905
|
+
yield* fs.writeFileString(files.plugin, draft.plugin + "\n");
|
|
906
|
+
yield* fs.writeFileString(files.test, test);
|
|
907
|
+
yield* fs.writeFileString(
|
|
908
|
+
files.config,
|
|
909
|
+
JSON.stringify(enabledPlugin(config.config, draft.pluginName, draft.ruleName), null, 2) +
|
|
910
|
+
"\n",
|
|
911
|
+
);
|
|
912
|
+
const refreshed = yield* refreshReadme(root);
|
|
913
|
+
return yield* outro(
|
|
914
|
+
"Wrote " +
|
|
915
|
+
path.relative(root, files.plugin) +
|
|
916
|
+
" and its test.\n" +
|
|
917
|
+
trialStatus(outcome) +
|
|
918
|
+
"\n" +
|
|
919
|
+
refreshed.message,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
if (scope === "project") {
|
|
923
|
+
const files = projectFiles(root, draft.pluginName);
|
|
924
|
+
const write = yield* Prompt.run(
|
|
925
|
+
Prompt.Confirm({
|
|
926
|
+
message:
|
|
927
|
+
"Write " +
|
|
928
|
+
path.relative(root, files.plugin) +
|
|
929
|
+
" and " +
|
|
930
|
+
path.relative(root, files.test) +
|
|
931
|
+
"?",
|
|
932
|
+
initial: true,
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
935
|
+
if (!write) return yield* outro("Nothing written.");
|
|
936
|
+
yield* fs.makeDirectory(path.dirname(files.plugin), { recursive: true });
|
|
937
|
+
yield* fs.writeFileString(files.plugin, draft.plugin + "\n");
|
|
938
|
+
yield* fs.writeFileString(files.test, test);
|
|
939
|
+
yield* line("");
|
|
940
|
+
yield* note(
|
|
941
|
+
"Add this to the project's oxlint config (oxlint.config.ts, or the lint block in vite.config.ts); adhere does not edit it:",
|
|
942
|
+
);
|
|
943
|
+
yield* line(pluginSnippet(draft.pluginName, draft.ruleName));
|
|
944
|
+
return yield* outro(
|
|
945
|
+
"Wrote " +
|
|
946
|
+
path.relative(root, files.plugin) +
|
|
947
|
+
" and its test.\n" +
|
|
948
|
+
trialStatus(outcome) +
|
|
949
|
+
"\nRun it with Node 22 or newer: node " +
|
|
950
|
+
path.relative(root, files.test) +
|
|
951
|
+
".",
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
if (shared === undefined) return yield* outro("Nothing written.");
|
|
955
|
+
const write = yield* Prompt.run(
|
|
956
|
+
Prompt.Confirm({ message: "Push the plugin to " + shared + "?", initial: true }),
|
|
957
|
+
);
|
|
958
|
+
if (!write) return yield* outro("Nothing written.");
|
|
959
|
+
const repo = yield* spinning("Cloning " + shared, cloned({ repo: shared, rule: "" }));
|
|
960
|
+
const files = sharedFiles(repo, draft.pluginName);
|
|
961
|
+
const config = yield* readSharedConfig(files.config);
|
|
962
|
+
if (config._tag === "Invalid") {
|
|
963
|
+
yield* warn(files.config + " is not valid JSON; left it unchanged.");
|
|
964
|
+
return yield* outro("Nothing written.");
|
|
965
|
+
}
|
|
966
|
+
yield* fs.makeDirectory(path.dirname(files.plugin), { recursive: true });
|
|
967
|
+
yield* fs.writeFileString(files.plugin, draft.plugin + "\n");
|
|
968
|
+
yield* fs.writeFileString(files.test, test);
|
|
969
|
+
yield* fs.writeFileString(
|
|
970
|
+
files.config,
|
|
971
|
+
JSON.stringify(enabledPlugin(config.config, draft.pluginName, draft.ruleName), null, 2) +
|
|
972
|
+
"\n",
|
|
973
|
+
);
|
|
974
|
+
const indexed = yield* refreshIndexIfShared(repo);
|
|
975
|
+
const branch = "adhere/oxlint-" + draft.pluginName + "-" + draft.ruleName;
|
|
976
|
+
const changed = [files.plugin, files.test, files.config].map((file) =>
|
|
977
|
+
path.relative(repo, file),
|
|
978
|
+
);
|
|
979
|
+
if (indexed) changed.push("README.md");
|
|
980
|
+
yield* git(repo, ["switch", "--quiet", "--create", branch]);
|
|
981
|
+
yield* git(repo, ["add", "--", ...changed]);
|
|
982
|
+
yield* git(repo, [
|
|
983
|
+
"commit",
|
|
984
|
+
"--quiet",
|
|
985
|
+
"--message",
|
|
986
|
+
"Add oxlint plugin " + draft.pluginName + "/" + draft.ruleName,
|
|
987
|
+
]);
|
|
988
|
+
yield* git(repo, ["push", "--quiet", "origin", branch]);
|
|
989
|
+
yield* line("");
|
|
990
|
+
yield* note(shared + "/" + SHARED_LINT_RULES + " now contains:");
|
|
991
|
+
yield* line(sharedSnippet(draft.pluginName, draft.ruleName));
|
|
992
|
+
yield* outro(
|
|
993
|
+
[
|
|
994
|
+
"Pushed " + branch + " to " + shared + ".",
|
|
995
|
+
trialStatus(outcome),
|
|
996
|
+
"Open the pull request: https://github.com/" + shared + "/compare/" + branch + "?expand=1",
|
|
997
|
+
].join("\n"),
|
|
998
|
+
);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* The whole oxlint route: a native rule is shown and enabled, or a custom JS
|
|
1003
|
+
* plugin is generated, approved, checked, and written. The wizard only chooses
|
|
1004
|
+
* that the rule goes here.
|
|
1005
|
+
*/
|
|
1006
|
+
export const createOxlintRule = (work: OxlintWork) =>
|
|
1007
|
+
Effect.gen(function* () {
|
|
1008
|
+
const native = work.shape.linter === null ? undefined : oxlintRule(work.shape.linter);
|
|
1009
|
+
return native !== undefined
|
|
1010
|
+
? yield* createBuiltin(work.agent, work.idea, native, work.shape, work.root, work.shared)
|
|
1011
|
+
: yield* createPlugin(work.agent, work.idea, work.shape, work.root, work.shared);
|
|
1012
|
+
});
|