@drkmttr/adhere 0.18.1 → 0.23.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.
Files changed (43) hide show
  1. package/README.md +62 -1575
  2. package/package.json +18 -15
  3. package/presets/README.md +140 -0
  4. package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
  5. package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
  6. package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
  7. package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
  8. package/skills/adhere/SKILL.md +194 -29
  9. package/skills/adhere-fix/SKILL.md +1 -1
  10. package/skills/adhere-setup/SKILL.md +60 -26
  11. package/src/cli.ts +334 -36
  12. package/src/config.ts +173 -142
  13. package/src/create/agents.ts +149 -0
  14. package/src/create/ask.ts +363 -0
  15. package/src/create/examples.ts +94 -0
  16. package/src/create/look.ts +191 -0
  17. package/src/create/oxlint-trial.ts +464 -0
  18. package/src/create/oxlint.ts +1012 -0
  19. package/src/create/trial-review.ts +562 -0
  20. package/src/create/trial.ts +611 -0
  21. package/src/create/wizard.ts +466 -0
  22. package/src/index.ts +2 -0
  23. package/src/init.ts +171 -41
  24. package/src/install.ts +296 -94
  25. package/src/instructions.ts +98 -0
  26. package/src/markdown.ts +15 -11
  27. package/src/oxlint/catalog.json +7844 -0
  28. package/src/oxlint/catalog.ts +72 -0
  29. package/src/oxlint/shared.ts +140 -0
  30. package/src/readme-guide.ts +431 -0
  31. package/src/readme.ts +291 -0
  32. package/src/rules.ts +42 -45
  33. package/src/services/AdhereConfig.ts +67 -21
  34. package/src/services/AuditCache.ts +176 -169
  35. package/src/services/Credentials.ts +14 -8
  36. package/src/services/Jev.reads.test.ts +22 -0
  37. package/src/services/Jev.ts +35 -13
  38. package/src/services/SourceWalker.ts +12 -5
  39. package/src/wording.ts +4 -4
  40. package/src/workflows/audit.ts +85 -14
  41. package/src/workflows/format.ts +98 -1
  42. package/src/workspaces.test.ts +37 -1
  43. package/src/workspaces.ts +121 -12
@@ -0,0 +1,464 @@
1
+ /**
2
+ * The trial's oxlint side: the installed linter the project already has, run
3
+ * against the selected files with a temporary config that enables exactly the
4
+ * draft rule. Nothing is installed, and no project config or plugin is
5
+ * written: the config and a custom plugin live for the run, then are gone.
6
+ *
7
+ * `src/create/oxlint.ts` builds that config for the wizard; this module only
8
+ * runs it, so the wizard's checks and this execution stay separate.
9
+ */
10
+ import { Effect, FileSystem, Path, Schema } from "effect";
11
+ import { existsSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+
15
+ /** Where a trial looks: at most this many files, or all of them. */
16
+ export type TrialScope =
17
+ | { readonly kind: "files"; readonly limit: number }
18
+ | { readonly kind: "all" };
19
+
20
+ /** A draft rule as the wizard has it, before anything is written. */
21
+ export interface AdhereTrialDraft {
22
+ readonly kind: "adhere";
23
+ readonly id: string;
24
+ readonly format: "markdown" | "typescript";
25
+ readonly source: string;
26
+ }
27
+
28
+ /** A native oxlint rule, with the config the wizard built for it. */
29
+ export interface NativeTrialDraft {
30
+ readonly kind: "native";
31
+ readonly id: string;
32
+ readonly options?: ReadonlyArray<unknown>;
33
+ /** `enabledNative({}, id, options)`, or the project config it came from. */
34
+ readonly config: unknown;
35
+ }
36
+
37
+ /** A custom oxlint JS plugin, with the config the wizard built for it. */
38
+ export interface PluginTrialDraft {
39
+ readonly kind: "plugin";
40
+ readonly pluginName: string;
41
+ readonly ruleName: string;
42
+ /** The plugin module's source, as it would be written to `.mjs`. */
43
+ readonly plugin: string;
44
+ /** `enabledPlugin({}, pluginName, ruleName)`, or the project config it came from. */
45
+ readonly config: unknown;
46
+ }
47
+
48
+ export type OxlintTrialDraft = NativeTrialDraft | PluginTrialDraft;
49
+ export type TrialDraft = AdhereTrialDraft | OxlintTrialDraft;
50
+
51
+ /** What a trial shares with a rerun: exact files, and whether to keep the report. */
52
+ export interface TrialOptions {
53
+ /**
54
+ * Repo-relative paths from an earlier `TrialRun.selectedFiles`, used
55
+ * verbatim. A path that is no longer eligible fails the trial rather than
56
+ * quietly choosing something else.
57
+ */
58
+ readonly files?: ReadonlyArray<string>;
59
+ /** Default true. False runs the trial without writing `.adhere/trial/`. */
60
+ readonly writeReport?: boolean;
61
+ }
62
+
63
+ /** Why a trial could not run, as the wizard explains it before the user skips. */
64
+ export type TrialUnavailableReason =
65
+ | "provider"
66
+ | "oxlint"
67
+ | "runtime"
68
+ | "compiler"
69
+ | "selection"
70
+ | "draft"
71
+ | "other";
72
+
73
+ /** A trial that could not run, or could not finish: never a made-up result. */
74
+ export class TrialUnavailable extends Schema.TaggedError<TrialUnavailable>()("TrialUnavailable", {
75
+ reason: Schema.Literals([
76
+ "provider",
77
+ "oxlint",
78
+ "runtime",
79
+ "compiler",
80
+ "selection",
81
+ "draft",
82
+ "other",
83
+ ]),
84
+ message: Schema.String,
85
+ }) {}
86
+
87
+ /** Lines of a file, first to last, counting from 1. */
88
+ export interface TrialRange {
89
+ readonly first: number;
90
+ readonly last: number;
91
+ }
92
+
93
+ /** One finding the trial reports: adhere's, or one oxlint diagnostic. */
94
+ export interface TrialFinding {
95
+ readonly rule: string;
96
+ /** Repo-relative. */
97
+ readonly file: string;
98
+ readonly section?: TrialRange;
99
+ readonly lines?: TrialRange;
100
+ readonly excerpt?: string;
101
+ /** The linter's own message, for an oxlint finding. */
102
+ readonly message?: string;
103
+ readonly probability?: number;
104
+ readonly threshold?: number;
105
+ readonly context?: number;
106
+ }
107
+
108
+ export interface TrialPreview {
109
+ /** Applicable files the root-wide walk found, before the cap. */
110
+ readonly eligible: number;
111
+ /** Files this scope will report on: the cap applied, cached files included. */
112
+ readonly selected: number;
113
+ readonly requests: number;
114
+ readonly tokens: number;
115
+ readonly price?: number;
116
+ readonly cached: number;
117
+ readonly deferred: number;
118
+ readonly skipped: number;
119
+ readonly model?: string;
120
+ readonly provider?: string;
121
+ }
122
+
123
+ export interface TrialRun {
124
+ readonly draft: TrialDraft;
125
+ readonly scope: TrialScope;
126
+ /** Identity of the draft, scope, exact files, and inherited runtime settings. */
127
+ readonly revision: string;
128
+ /** Exact repo-relative paths, sorted, as they were run; pass to `options.files` to rerun. */
129
+ readonly selectedFiles: ReadonlyArray<string>;
130
+ readonly files: number;
131
+ /** Files this run actually judged (adhere) or linted (oxlint). */
132
+ readonly judged: number;
133
+ readonly cached: number;
134
+ readonly skipped: number;
135
+ readonly waiting: number;
136
+ readonly findings: ReadonlyArray<TrialFinding>;
137
+ /** Repo-relative. Written only when `writeReport` is true. */
138
+ readonly reportPath: string;
139
+ readonly provider?: string;
140
+ /** Factual limits of this run, never a claim that the rule is validated. */
141
+ readonly notes: ReadonlyArray<string>;
142
+ }
143
+
144
+ /** What running the project's oxlint produced. */
145
+ export interface OxlintRun {
146
+ readonly findings: ReadonlyArray<TrialFinding>;
147
+ readonly judged: number;
148
+ readonly diagnostics: ReadonlyArray<unknown>;
149
+ readonly binary: string;
150
+ readonly notes: ReadonlyArray<string>;
151
+ }
152
+
153
+ /** What oxlint's `--format=json` writes, as much of it as a trial reads. */
154
+ const Span = Schema.Struct({
155
+ line: Schema.optionalKey(Schema.Finite),
156
+ column: Schema.optionalKey(Schema.Finite),
157
+ });
158
+ const Label = Schema.Struct({ span: Schema.optionalKey(Span) });
159
+ const Diagnostic = Schema.Struct({
160
+ message: Schema.String,
161
+ code: Schema.optionalKey(Schema.String),
162
+ severity: Schema.optionalKey(Schema.String),
163
+ filename: Schema.String,
164
+ labels: Schema.optionalKey(Schema.Array(Label)),
165
+ });
166
+ interface Diagnostic {
167
+ readonly message: string;
168
+ readonly code?: string;
169
+ readonly severity?: string;
170
+ readonly filename: string;
171
+ readonly line?: number;
172
+ readonly column?: number;
173
+ }
174
+ const Output = Schema.Struct({
175
+ diagnostics: Schema.Array(Schema.Unknown),
176
+ number_of_files: Schema.optionalKey(Schema.Finite),
177
+ });
178
+ const decodeOutput = Schema.decodeUnknownOption(Schema.fromJsonString(Output));
179
+ const decodeDiagnostic = Schema.decodeUnknownOption(Diagnostic);
180
+
181
+ /** A package the consumer installed, found from its root up. */
182
+ const installedIn = (root: string, name: string): string | undefined => {
183
+ let directory = root;
184
+ for (;;) {
185
+ const found = join(directory, "node_modules", name, "package.json");
186
+ if (existsSync(found)) return found;
187
+ const parent = dirname(directory);
188
+ if (parent === directory) return undefined;
189
+ directory = parent;
190
+ }
191
+ };
192
+
193
+ /** The project's own oxlint, never one adhere bundles. */
194
+ export const oxlintBinary = (root: string): string | undefined => {
195
+ let directory = root;
196
+ for (;;) {
197
+ for (const candidate of [
198
+ join(directory, "node_modules", ".bin", "oxlint"),
199
+ join(directory, "node_modules", "oxlint", "bin", "oxlint"),
200
+ ]) {
201
+ if (existsSync(candidate)) return candidate;
202
+ }
203
+ const parent = dirname(directory);
204
+ if (parent === directory) return undefined;
205
+ directory = parent;
206
+ }
207
+ };
208
+
209
+ /** The rule id a trial runs, as a report names it. */
210
+ export const oxlintIdOf = (draft: OxlintTrialDraft): string =>
211
+ draft.kind === "native" ? draft.id : `${draft.pluginName}/${draft.ruleName}`;
212
+
213
+ /** Whether the draft's config asks oxlint for type-aware checks. */
214
+ export const oxlintTypeAware = (draft: OxlintTrialDraft): boolean => {
215
+ const config = draft.config;
216
+ if (typeof config !== "object" || config === null || !("options" in config)) return false;
217
+ const { options } = config;
218
+ if (typeof options !== "object" || options === null || !("typeAware" in options)) return false;
219
+ return options.typeAware === true;
220
+ };
221
+
222
+ /**
223
+ * What keeps the draft from running here: no consumer oxlint, a type-aware
224
+ * rule without the compiler oxlint needs, or an empty plugin.
225
+ */
226
+ export const oxlintProblems = (
227
+ draft: OxlintTrialDraft,
228
+ root: string,
229
+ ): ReadonlyArray<{ readonly reason: TrialUnavailableReason; readonly message: string }> => [
230
+ ...(oxlintBinary(root) === undefined
231
+ ? [
232
+ {
233
+ reason: "oxlint" as const,
234
+ message:
235
+ "This project has no oxlint to run the draft with: install it (bun add --dev oxlint, or use the Vite+ version) and run this again. adhere does not bundle oxlint.",
236
+ },
237
+ ]
238
+ : []),
239
+ ...(oxlintTypeAware(draft) && installedIn(root, "oxlint-tsgolint") === undefined
240
+ ? [
241
+ {
242
+ reason: "compiler" as const,
243
+ message:
244
+ "This draft's rule needs TypeScript types, but the project has no oxlint-tsgolint: add it (bun add --dev oxlint-tsgolint) to run type-aware rules. The trial did not run.",
245
+ },
246
+ ]
247
+ : []),
248
+ ...(draft.kind === "plugin" && draft.plugin.trim() === ""
249
+ ? [{ reason: "draft" as const, message: "The plugin draft is empty." }]
250
+ : []),
251
+ ];
252
+
253
+ /** A record from the wizard's config, or an empty one when it is not an object. */
254
+ const configRecord = (config: unknown): Record<string, unknown> =>
255
+ typeof config === "object" && config !== null && !Array.isArray(config)
256
+ ? (config as Record<string, unknown>)
257
+ : {};
258
+
259
+ /** Every oxlint category, off: the trial turns on the draft rule and nothing else. */
260
+ const NO_CATEGORIES = {
261
+ correctness: "off",
262
+ suspicious: "off",
263
+ pedantic: "off",
264
+ perf: "off",
265
+ style: "off",
266
+ restriction: "off",
267
+ nursery: "off",
268
+ } as const;
269
+
270
+ /** A config with exactly the draft rule on: no categories, no other rules, no inherited JS plugins. */
271
+ const isolatedConfig = (draft: OxlintTrialDraft): unknown => {
272
+ const {
273
+ rules,
274
+ categories: _categories,
275
+ extends: _extends,
276
+ jsPlugins: _plugins,
277
+ } = configRecord(draft.config);
278
+ const base = configRecord(draft.config);
279
+ const settings = configRecord(rules);
280
+ const id = oxlintIdOf(draft);
281
+ return draft.kind === "native"
282
+ ? { ...base, categories: NO_CATEGORIES, rules: { [id]: settings[id] ?? "error" } }
283
+ : {
284
+ ...base,
285
+ categories: NO_CATEGORIES,
286
+ jsPlugins: [{ name: draft.pluginName, specifier: "./plugin.mjs" }],
287
+ rules: { [id]: settings[id] ?? "error" },
288
+ };
289
+ };
290
+
291
+ /** A diagnostic's code as a rule id: `eslint(no-console)` is `no-console`, `acme(no-foo)` is `acme/no-foo`. */
292
+ const ruleIdOf = (code: string): string => {
293
+ const matched = /^([^(]+)\((.+)\)$/.exec(code);
294
+ if (matched === null) return code;
295
+ const [, plugin, rule] = matched;
296
+ return plugin === "eslint" ? rule! : `${plugin}/${rule}`;
297
+ };
298
+
299
+ /** A decoded diagnostic with its first label's line, or undefined when it is not one. */
300
+ const parsedDiagnostic = (value: unknown): Diagnostic | undefined => {
301
+ const decoded = decodeDiagnostic(value);
302
+ if (decoded._tag === "None" || decoded.value.filename === "") return undefined;
303
+ const span = decoded.value.labels?.[0]?.span;
304
+ return {
305
+ message: decoded.value.message,
306
+ ...(decoded.value.code === undefined ? {} : { code: decoded.value.code }),
307
+ ...(decoded.value.severity === undefined ? {} : { severity: decoded.value.severity }),
308
+ filename: decoded.value.filename,
309
+ ...(span?.line === undefined ? {} : { line: span.line }),
310
+ ...(span?.column === undefined ? {} : { column: span.column }),
311
+ };
312
+ };
313
+
314
+ interface Parsed {
315
+ readonly diagnostics: ReadonlyArray<Diagnostic>;
316
+ readonly files: number;
317
+ }
318
+
319
+ /** Oxlint's JSON on stdout, whatever it wrote around it; undefined when it wrote none. */
320
+ const parse = (stdout: string): Parsed | undefined => {
321
+ const start = stdout.indexOf("{");
322
+ const end = stdout.lastIndexOf("}");
323
+ if (start === -1 || end <= start) return undefined;
324
+ const decoded = decodeOutput(stdout.slice(start, end + 1));
325
+ if (decoded._tag === "None") return undefined;
326
+ return {
327
+ diagnostics: decoded.value.diagnostics.flatMap((diagnostic) => {
328
+ const parsed = parsedDiagnostic(diagnostic);
329
+ return parsed === undefined ? [] : [parsed];
330
+ }),
331
+ files: decoded.value.number_of_files ?? 0,
332
+ };
333
+ };
334
+
335
+ /** One oxlint invocation over at most this many files, to stay under the argument limit. */
336
+ const CHUNK = 100;
337
+
338
+ const tail = (text: string) => text.trim().slice(-4000);
339
+
340
+ const failed = (message: string, reason: TrialUnavailableReason = "oxlint") =>
341
+ TrialUnavailable.make({ reason, message });
342
+
343
+ /**
344
+ * Runs the project's oxlint over exactly `selectedFiles`, with a config that
345
+ * enables only the draft rule and, for a plugin, a temporary module. The
346
+ * temporary directory is scoped to this effect.
347
+ */
348
+ export const runOxlintDraft = (
349
+ root: string,
350
+ draft: OxlintTrialDraft,
351
+ selectedFiles: ReadonlyArray<string>,
352
+ ): Effect.Effect<OxlintRun, TrialUnavailable, FileSystem.FileSystem | Path.Path> =>
353
+ Effect.gen(function* () {
354
+ const problems = oxlintProblems(draft, root);
355
+ const first = problems[0];
356
+ if (first !== undefined) return yield* failed(first.message, first.reason);
357
+ const binary = oxlintBinary(root)!;
358
+ if (selectedFiles.length === 0) {
359
+ return {
360
+ findings: [],
361
+ judged: 0,
362
+ diagnostics: [],
363
+ binary,
364
+ notes: ["No applicable files to lint."],
365
+ };
366
+ }
367
+ const fs = yield* FileSystem.FileSystem;
368
+ const path = yield* Path.Path;
369
+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "adhere-trial-oxlint-" });
370
+ const config = path.join(directory, "oxlint.json");
371
+ yield* fs.writeFileString(config, `${JSON.stringify(isolatedConfig(draft), null, 2)}\n`);
372
+ if (draft.kind === "plugin") {
373
+ yield* fs.writeFileString(path.join(directory, "plugin.mjs"), `${draft.plugin}\n`);
374
+ }
375
+
376
+ const diagnostics: Array<Diagnostic> = [];
377
+ let judged = 0;
378
+ for (let at = 0; at < selectedFiles.length; at += CHUNK) {
379
+ const files = selectedFiles.slice(at, at + CHUNK);
380
+ const result = yield* Effect.sync(() =>
381
+ spawnSync(binary, ["-c", config, "--format=json", ...files], {
382
+ cwd: root,
383
+ encoding: "utf8",
384
+ maxBuffer: 64 * 1024 * 1024,
385
+ }),
386
+ );
387
+ const stdout = result.stdout ?? "";
388
+ const parsed = parse(stdout);
389
+ if (parsed === undefined) {
390
+ const detail = tail(`${result.stderr ?? ""}\n${stdout}`) || "no output";
391
+ return yield* failed(`oxlint did not report diagnostics as JSON: ${detail}`);
392
+ }
393
+ diagnostics.push(...parsed.diagnostics);
394
+ judged += parsed.files;
395
+ // Diagnostics are the linter's answer, and exit 1; anything else answers nothing.
396
+ if (parsed.diagnostics.length === 0 && result.status !== 0) {
397
+ return yield* failed(
398
+ `oxlint exited ${result.status ?? "without a status"}: ${tail(result.stderr ?? "") || "no output"}`,
399
+ );
400
+ }
401
+ }
402
+
403
+ const files = [...new Set(diagnostics.map((diagnostic) => diagnostic.filename))];
404
+ const contents = new Map(
405
+ yield* Effect.forEach(
406
+ files,
407
+ (file) =>
408
+ Effect.map(
409
+ fs.readFileString(path.resolve(root, file)).pipe(Effect.orElseSucceed(() => "")),
410
+ (text) => [file, text.split("\n")] as const,
411
+ ),
412
+ { concurrency: 8 },
413
+ ),
414
+ );
415
+ const expected = oxlintIdOf(draft);
416
+ const notes: string[] = [];
417
+ if (judged < selectedFiles.length) {
418
+ notes.push(
419
+ `oxlint read ${judged} of ${selectedFiles.length} selected files; oxlint still applies its built-in extension checks and .gitignore to explicitly named files, but this trial's temporary config does not copy the project's ignorePatterns.`,
420
+ );
421
+ }
422
+ const mapped = diagnostics
423
+ .map((diagnostic): TrialFinding => {
424
+ const file = diagnostic.filename.replaceAll("\\", "/").replace(/^\.\//, "");
425
+ const lines = contents.get(diagnostic.filename) ?? contents.get(file);
426
+ const line = diagnostic.line;
427
+ const section = line === undefined ? undefined : { first: line, last: line };
428
+ return {
429
+ rule: ruleIdOf(diagnostic.code ?? "oxlint"),
430
+ file,
431
+ ...(section === undefined ? {} : { section, lines: section }),
432
+ ...(lines === undefined || line === undefined ? {} : { excerpt: lines[line - 1] ?? "" }),
433
+ message: diagnostic.message,
434
+ };
435
+ })
436
+ .sort(
437
+ (a, b) =>
438
+ a.file.localeCompare(b.file) ||
439
+ (a.lines?.first ?? 0) - (b.lines?.first ?? 0) ||
440
+ a.message!.localeCompare(b.message!),
441
+ );
442
+ const unexpected = mapped.filter((finding) => finding.rule !== expected).length;
443
+ if (unexpected > 0) {
444
+ notes.push(
445
+ `${unexpected} diagnostics named another rule than ${expected}; the temporary config enables only the draft, so they are shown for what they are.`,
446
+ );
447
+ }
448
+ return {
449
+ findings: mapped,
450
+ judged,
451
+ diagnostics,
452
+ binary,
453
+ notes,
454
+ } satisfies OxlintRun;
455
+ }).pipe(
456
+ Effect.scoped,
457
+ Effect.mapError((problem) =>
458
+ problem instanceof TrialUnavailable
459
+ ? problem
460
+ : failed(
461
+ `The oxlint trial could not run in a temporary directory: ${problem instanceof Error ? problem.message : String(problem)}`,
462
+ ),
463
+ ),
464
+ );