@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,466 @@
1
+ /**
2
+ * `adhere rules create`: a wizard that writes one rule with the user, asking
3
+ * an installed coding agent three times along the way. It suggests a scope,
4
+ * names, a RULE.md or a RULE.ts, and the context Jev needs; finds code in the
5
+ * repo for the user to judge; and writes the rule, which adhere checks as
6
+ * `validate` does and sends back once when it falls short. A rule for the
7
+ * organization goes to its shared rules repo, cloned, on a branch pushed for a
8
+ * pull request. The first run, or `--setup`, saves which agent to ask and
9
+ * which repo is the organization's.
10
+ */
11
+ import { CONFIG_FILES, DEFAULT_READS } from "#config.ts";
12
+ import { AGENTS, type Agent, installedAgents, run } from "#create/agents.ts";
13
+ import {
14
+ RuleNotCreated,
15
+ Shape,
16
+ adhereRevisionPrompt,
17
+ asking,
18
+ askingFor,
19
+ shapePrompt,
20
+ unfenced,
21
+ writePrompt,
22
+ } from "#create/ask.ts";
23
+ import { reviewedInRepository } from "#create/examples.ts";
24
+ import { createOxlintRule } from "#create/oxlint.ts";
25
+ import { trialAndReview, trialStatus, trialWriteSuffix } from "#create/trial-review.ts";
26
+ import { oxlintRule } from "#oxlint/catalog.ts";
27
+ import {
28
+ decided,
29
+ indented,
30
+ intro,
31
+ line,
32
+ look,
33
+ note,
34
+ outro,
35
+ renderMarkdown,
36
+ spinning,
37
+ warn,
38
+ } from "#create/look.ts";
39
+ import { cloned } from "#install.ts";
40
+ import { parseRuleMarkdown } from "#markdown.ts";
41
+ import { isSharedRepo, refreshIndexIfShared, refreshReadme, validateReadme } from "#readme.ts";
42
+ import { importRule } from "#rules.ts";
43
+ import { findConfigFile, loadConfigFile } from "#services/AdhereConfig.ts";
44
+ import { adhereConfigDirectory } from "#services/Credentials.ts";
45
+ import { sentenceOf, strayingOf } from "#wording.ts";
46
+ import { Config, Effect, FileSystem, Option, Path, Schema } from "effect";
47
+ import { Prompt } from "effect/cli";
48
+
49
+ const RULE_ID = /^[a-z0-9]+(-[a-z0-9]+)*(\/[a-z0-9]+(-[a-z0-9]+)*)+$/;
50
+
51
+ /** What setup saves: the agent to ask, and the organization's shared rules repo, when it has one. */
52
+ const Settings = Schema.Struct({
53
+ agent: Schema.Literals(AGENTS),
54
+ sharedRules: Schema.optionalKey(Schema.String),
55
+ });
56
+ type Settings = typeof Settings.Type;
57
+
58
+ /** `settings.json` beside `adhere login`'s keys. */
59
+ const settingsFile = Effect.gen(function* () {
60
+ const path = yield* Path.Path;
61
+ return yield* adhereConfigDirectory(path).pipe(
62
+ Config.map((directory) => path.join(directory, "settings.json")),
63
+ Effect.mapError(
64
+ () =>
65
+ new RuleNotCreated({
66
+ message: "No home directory to keep settings in: set HOME or XDG_CONFIG_HOME.",
67
+ }),
68
+ ),
69
+ );
70
+ });
71
+
72
+ const setUp = (file: string, named: Option.Option<Agent>) =>
73
+ Effect.gen(function* () {
74
+ const fs = yield* FileSystem.FileSystem;
75
+ const path = yield* Path.Path;
76
+ const installed = installedAgents();
77
+ const [only, ...others] = installed;
78
+ if (Option.isNone(named) && only === undefined) {
79
+ return yield* new RuleNotCreated({
80
+ message:
81
+ "No coding agent found on PATH: install claude, codex, or omp, or hand `adhere skill docs` to the agent you use.",
82
+ });
83
+ }
84
+ const agent = Option.isSome(named)
85
+ ? named.value
86
+ : only !== undefined && others.length === 0
87
+ ? only
88
+ : yield* Prompt.run(
89
+ Prompt.Select({
90
+ message: "Which agent should help write rules?",
91
+ choices: installed.map((agent) => ({ title: agent, value: agent })),
92
+ }),
93
+ );
94
+ const shared = yield* Prompt.run(
95
+ Prompt.String({
96
+ message: "Your organization's shared rules repo on GitHub, as org/repo (empty for none)",
97
+ validate: (value) =>
98
+ value === "" || /^[\w.-]+\/[\w.-]+$/.test(value)
99
+ ? Effect.succeed(value)
100
+ : Effect.fail("org/repo, as on GitHub, or empty."),
101
+ }),
102
+ );
103
+ const settings: Settings = { agent, ...(shared === "" ? {} : { sharedRules: shared }) };
104
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true });
105
+ yield* fs.writeFileString(file, `${JSON.stringify(settings, null, 2)}\n`);
106
+ yield* note(`Saved to ${file}. Run adhere rules create --setup to change it.`);
107
+ return settings;
108
+ });
109
+
110
+ const failed = (cause: unknown) =>
111
+ new RuleNotCreated({ message: cause instanceof Error ? cause.message : String(cause) });
112
+
113
+ const git = (cwd: string, args: ReadonlyArray<string>) =>
114
+ Effect.tryPromise({ try: () => run("git", args, cwd, ""), catch: failed });
115
+
116
+ /**
117
+ * What adhere can say of a draft without running it: for a RULE.md, what
118
+ * `validate` would; for a RULE.ts, whether it parses and has a default export.
119
+ */
120
+ const problemsOf = (text: string, file: string, format: Shape["format"]) =>
121
+ format === "markdown"
122
+ ? parseRuleMarkdown(text, file).pipe(
123
+ Effect.map((rule) => strayingOf(rule).map(sentenceOf)),
124
+ Effect.catchTag("ConfigUnavailable", ({ message }) => Effect.succeed([message])),
125
+ )
126
+ : Effect.try({
127
+ try: () => new Bun.Transpiler({ loader: "ts" }).scan(text).exports,
128
+ catch: (cause) => (cause instanceof Error ? cause.message : String(cause)),
129
+ }).pipe(
130
+ Effect.map((exports) =>
131
+ exports.includes("default") ? [] : ["It has no default export: defineRule({...})."],
132
+ ),
133
+ Effect.catch((message) => Effect.succeed([`It does not parse: ${message}`])),
134
+ );
135
+
136
+ /**
137
+ * Shows a draft as the user reads it: a RULE.md rendered from Markdown with
138
+ * its frontmatter and source intact, a RULE.ts as written, either on the rail.
139
+ * Only the preview is rendered; the draft itself stays raw for the agent, the
140
+ * trial, and the file.
141
+ */
142
+ const showDraft = (text: string, format: Shape["format"]) =>
143
+ Effect.gen(function* () {
144
+ const shown = format === "markdown" ? yield* renderMarkdown(text) : text;
145
+ yield* line(`\n${shown}\n`);
146
+ });
147
+
148
+ /** A RULE.ts that imported, with its wording strays, or one that did not import. */
149
+ export type LoadedDraft =
150
+ | { readonly _tag: "Loaded"; readonly warnings: ReadonlyArray<string> }
151
+ | { readonly _tag: "Unavailable"; readonly problems: ReadonlyArray<string> };
152
+
153
+ /**
154
+ * What `validate` would say of a RULE.ts the user has approved: imported from
155
+ * a temporary directory, so its code runs. A wording stray is a warning about
156
+ * a rule that still runs; only a failure to import makes it untriallable.
157
+ */
158
+ export const loadedDraft = (text: string, shown: string) =>
159
+ Effect.gen(function* () {
160
+ const fs = yield* FileSystem.FileSystem;
161
+ const path = yield* Path.Path;
162
+ const file = path.join(
163
+ yield* fs.makeTempDirectoryScoped({ prefix: "adhere-rule-" }),
164
+ "RULE.ts",
165
+ );
166
+ yield* fs.writeFileString(file, text);
167
+ return yield* importRule(file).pipe(
168
+ Effect.map((rule): LoadedDraft => ({
169
+ _tag: "Loaded",
170
+ warnings: strayingOf(rule).map(sentenceOf),
171
+ })),
172
+ Effect.catchTag("ConfigUnavailable", ({ message }): Effect.Effect<LoadedDraft> =>
173
+ Effect.succeed({
174
+ _tag: "Unavailable",
175
+ problems: [message.replaceAll(file, shown)],
176
+ }),
177
+ ),
178
+ );
179
+ });
180
+
181
+ /**
182
+ * Whether the trial may import and run the draft: a RULE.md has no code, while
183
+ * a RULE.ts is imported only after the user approves it.
184
+ */
185
+ const approveDraft = (text: string, format: Shape["format"], shown: string) =>
186
+ Effect.gen(function* () {
187
+ if (format === "markdown") return { _tag: "Approved", draft: text } as const;
188
+ const run = yield* Prompt.run(
189
+ Prompt.Confirm({
190
+ message: "adhere will import this RULE.ts and run its code to trial it. Run it?",
191
+ initial: true,
192
+ }),
193
+ );
194
+ if (!run) return { _tag: "Declined" } as const;
195
+ const loaded = yield* loadedDraft(text, shown);
196
+ if (loaded._tag === "Unavailable") {
197
+ return { _tag: "Unavailable", problems: loaded.problems } as const;
198
+ }
199
+ for (const warning of loaded.warnings) yield* warn(warning);
200
+ return { _tag: "Approved", draft: text } as const;
201
+ });
202
+
203
+ /**
204
+ * The config settings the rule needs that `config` lacks, as they would be
205
+ * written in it. The rule declares its reads, but the config still enables
206
+ * them and type awareness.
207
+ */
208
+ const settingsFor = (
209
+ shape: Shape,
210
+ config: { readonly typeAware?: boolean; readonly reads?: ReadonlyArray<string> },
211
+ ) => {
212
+ const reads = config.reads ?? DEFAULT_READS;
213
+ const missing = shape.reads.filter((read) => !reads.includes(read));
214
+ return [
215
+ ...(shape.typeAware && config.typeAware !== true ? ["typeAware: true"] : []),
216
+ ...(missing.length === 0
217
+ ? []
218
+ : [`reads: [${[...reads, ...missing].map((read) => `"${read}"`).join(", ")}]`]),
219
+ ];
220
+ };
221
+
222
+ export const createRule = Effect.fn("createRule")(
223
+ function* (given: Option.Option<string>, named: Option.Option<Agent>, setup: boolean) {
224
+ yield* intro("adhere rules create");
225
+ const fs = yield* FileSystem.FileSystem;
226
+ const path = yield* Path.Path;
227
+ const root = path.resolve();
228
+ const saved = yield* settingsFile;
229
+ const settings =
230
+ setup || !(yield* fs.exists(saved))
231
+ ? yield* setUp(saved, named)
232
+ : yield* fs.readFileString(saved).pipe(
233
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Settings))),
234
+ Effect.mapError(
235
+ (problem) =>
236
+ new RuleNotCreated({
237
+ message: `${saved}: ${problem.message}. Run adhere rules create --setup to write it again.`,
238
+ }),
239
+ ),
240
+ );
241
+ const agent = Option.getOrElse(named, () => settings.agent);
242
+ const shared = settings.sharedRules;
243
+ const idea = Option.isSome(given)
244
+ ? given.value
245
+ : yield* Prompt.run(Prompt.String({ message: "What should the rule enforce?" }));
246
+
247
+ const shape = yield* askingFor(agent, "how to shape it", root, shapePrompt(idea), Shape);
248
+ const native = shape.linter === null ? undefined : oxlintRule(shape.linter);
249
+ if (native !== undefined) {
250
+ return yield* createOxlintRule({ agent, idea, shape, root, shared });
251
+ }
252
+ if (shape.mechanical && !shape.typeAware) {
253
+ const approach = yield* Prompt.run(
254
+ Prompt.Select({
255
+ message: "A custom oxlint JS plugin can check this. Which do you want?",
256
+ choices: [
257
+ { title: "Write an oxlint plugin", value: "plugin" as const },
258
+ { title: "Write an adhere rule", value: "adhere" as const },
259
+ ],
260
+ }),
261
+ );
262
+ if (approach === "plugin") {
263
+ return yield* createOxlintRule({ agent, idea, shape, root, shared });
264
+ }
265
+ }
266
+ const ruleFile = shape.format === "typescript" ? "RULE.ts" : "RULE.md";
267
+ yield* decided(`A ${ruleFile}`, shape.formatReason);
268
+ yield* decided(
269
+ `Jev reads ${["the code", ...shape.reads, ...(shape.typeAware ? ["its types"] : [])].join(", ")}`,
270
+ shape.contextReason,
271
+ );
272
+
273
+ const scopes = [
274
+ { title: "This project", value: "project" as const, description: ".adhere/rules/" },
275
+ { title: "The organization", value: "org" as const, description: shared ?? "" },
276
+ ].map((choice) =>
277
+ choice.value === shape.scope
278
+ ? { ...choice, description: indented(shape.scopeReason) }
279
+ : choice,
280
+ );
281
+ if (shared === undefined && shape.scope === "org") {
282
+ yield* warn(
283
+ `${agent} suggests the organization's shared rules, but none is set up, so the rule goes in this project.\nRun adhere rules create --setup to name the shared repo.`,
284
+ );
285
+ }
286
+ const scope =
287
+ shared === undefined
288
+ ? "project"
289
+ : yield* Prompt.run(
290
+ Prompt.Select({
291
+ message: "Where does the rule belong?",
292
+ choices: shape.scope === "org" ? scopes.toReversed() : scopes,
293
+ }),
294
+ );
295
+ const repo =
296
+ scope === "org" && shared !== undefined
297
+ ? yield* spinning(`Cloning ${shared}`, cloned({ repo: shared, rule: "" }))
298
+ : root;
299
+ const rules = path.join(repo, ".adhere", "rules");
300
+
301
+ const free = (id: string) =>
302
+ fs.exists(path.join(rules, id)).pipe(
303
+ Effect.orElseSucceed(() => true),
304
+ Effect.map((taken) => !taken),
305
+ );
306
+ const suggested = yield* Effect.filter(
307
+ shape.names.filter((id) => RULE_ID.test(id)),
308
+ free,
309
+ );
310
+ const picked = yield* Prompt.run(
311
+ Prompt.Select({
312
+ message: "Name",
313
+ choices: [
314
+ ...suggested.map((id) => ({ title: id, value: Option.some(id) })),
315
+ { title: "Type my own", value: Option.none<string>() },
316
+ ],
317
+ }),
318
+ );
319
+ const id = Option.isSome(picked)
320
+ ? picked.value
321
+ : yield* Prompt.run(
322
+ Prompt.String({
323
+ message: "Rule id, as topic/rule-name",
324
+ validate: (value) =>
325
+ !RULE_ID.test(value)
326
+ ? Effect.fail("Lowercase kebab case, under a topic: topic/rule-name.")
327
+ : free(value).pipe(
328
+ Effect.flatMap((ok) =>
329
+ ok ? Effect.succeed(value) : Effect.fail("A rule by that id exists."),
330
+ ),
331
+ ),
332
+ }),
333
+ );
334
+
335
+ const reviewed = yield* reviewedInRepository(agent, root, idea, id);
336
+
337
+ const file = path.join(rules, id, ruleFile);
338
+ const branch = `adhere/${id}`;
339
+ const prompt = writePrompt(idea, id, shape, reviewed);
340
+ const draft = unfenced(yield* asking(agent, "to write the rule", root, prompt));
341
+ const draftProblems = yield* problemsOf(draft, file, shape.format);
342
+ const text =
343
+ draftProblems.length === 0
344
+ ? draft
345
+ : unfenced(
346
+ yield* asking(
347
+ agent,
348
+ "to fix the rule",
349
+ root,
350
+ `${prompt}\n\nYour last draft:\n\n${draft}\n\nadhere found these problems with it:\n${draftProblems.map((problem) => `- ${problem}`).join("\n")}\n\nFix them, and reply with only the corrected file.`,
351
+ ),
352
+ );
353
+ const problems = yield* problemsOf(text, file, shape.format);
354
+
355
+ yield* showDraft(text, shape.format);
356
+ for (const problem of problems) yield* warn(problem);
357
+ const shown = path.relative(root, file);
358
+ const outcome = yield* trialAndReview({
359
+ agent,
360
+ root,
361
+ id,
362
+ draft: text,
363
+ toDraft: (source) => ({ kind: "adhere", id, format: shape.format, source }),
364
+ approve: (source) => approveDraft(source, shape.format, shown),
365
+ revise: (source, evidence) =>
366
+ Effect.gen(function* () {
367
+ const revised = unfenced(
368
+ yield* asking(
369
+ agent,
370
+ "to revise the rule",
371
+ root,
372
+ adhereRevisionPrompt(idea, id, shape, source, evidence),
373
+ ),
374
+ );
375
+ for (const problem of yield* problemsOf(revised, file, shape.format)) {
376
+ yield* warn(problem);
377
+ }
378
+ return revised;
379
+ }),
380
+ show: (source) => showDraft(source, shape.format),
381
+ });
382
+ if (outcome._tag === "Cancelled") return yield* outro("Nothing written.");
383
+ const finalText = outcome.draft;
384
+ const finalProblems = yield* problemsOf(finalText, file, shape.format);
385
+ if (finalText !== text) {
386
+ yield* showDraft(finalText, shape.format);
387
+ for (const problem of finalProblems) yield* warn(problem);
388
+ }
389
+ const write = yield* Prompt.run(
390
+ Prompt.Confirm({
391
+ message:
392
+ (scope === "project"
393
+ ? `Write ${path.relative(root, file)}?`
394
+ : `Push it to ${shared} on the branch ${branch}?`) + trialWriteSuffix(outcome),
395
+ initial: finalProblems.length === 0 && outcome._tag === "Accepted",
396
+ }),
397
+ );
398
+ if (!write) return yield* outro("Nothing written.");
399
+ // A RULE.ts is only imported, and its code run, once the user has read it.
400
+ if (shape.format === "typescript") {
401
+ const loaded = yield* loadedDraft(finalText, path.relative(root, file));
402
+ const problems = loaded._tag === "Loaded" ? loaded.warnings : loaded.problems;
403
+ for (const problem of problems) yield* warn(problem);
404
+ if (
405
+ problems.length > 0 &&
406
+ !(yield* Prompt.run(Prompt.Confirm({ message: "Write it anyway?", initial: false })))
407
+ ) {
408
+ return yield* outro("Nothing written.");
409
+ }
410
+ }
411
+ const sharedHere = scope === "project" ? yield* isSharedRepo(root) : false;
412
+ if (sharedHere) yield* validateReadme(root);
413
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true });
414
+ yield* fs.writeFileString(file, `${finalText}\n`);
415
+
416
+ if (scope === "project") {
417
+ const configFile = yield* findConfigFile(root).pipe(Effect.orElseSucceed(() => undefined));
418
+ const config =
419
+ configFile === undefined
420
+ ? {}
421
+ : yield* loadConfigFile(path.join(root, configFile)).pipe(
422
+ Effect.orElseSucceed(() => ({})),
423
+ );
424
+ const needed = settingsFor(shape, config);
425
+ if (needed.length > 0) {
426
+ yield* warn(
427
+ `${id} needs ${needed.join(" and ")} in ${configFile ?? CONFIG_FILES[0]}${shape.typeAware ? " (or lint --type-aware)" : ""}. Until then, lint judges it without that context.`,
428
+ );
429
+ }
430
+ const refreshed = sharedHere ? yield* refreshReadme(root) : undefined;
431
+ return yield* outro(
432
+ `Wrote ${path.relative(root, file)}.\n${trialStatus(outcome)}\n${refreshed === undefined ? "" : `${refreshed.message}\n`}Run adhere validate to check it against your other rules.`,
433
+ );
434
+ }
435
+ const needed = settingsFor(shape, {});
436
+ const needs =
437
+ needed.length === 0
438
+ ? []
439
+ : [`Repos that install it need ${needed.join(" and ")} in their adhere config.`];
440
+ // A shared repo's index joins the rule in one commit and one pull request;
441
+ // a legacy rules-only repo has no index to refresh.
442
+ const indexed = yield* refreshIndexIfShared(repo);
443
+ yield* git(repo, ["switch", "--quiet", "--create", branch]);
444
+ const staged = [path.relative(repo, file)];
445
+ if (indexed) staged.push("README.md");
446
+ yield* git(repo, ["add", "--", ...staged]);
447
+ yield* git(repo, [
448
+ "commit",
449
+ "--quiet",
450
+ "--message",
451
+ `Add rule ${id}`,
452
+ ...needs.flatMap((body) => ["--message", body]),
453
+ ]);
454
+ yield* git(repo, ["push", "--quiet", "origin", branch]);
455
+ yield* outro(
456
+ [
457
+ `Pushed ${branch} to ${shared}.`,
458
+ trialStatus(outcome),
459
+ ...needs,
460
+ `Open the pull request: https://github.com/${shared}/compare/${branch}?expand=1`,
461
+ ].join("\n"),
462
+ );
463
+ },
464
+ Effect.scoped,
465
+ (effect) => Effect.provideService(effect, Prompt.Theme, Prompt.makeTheme(look)),
466
+ );
package/src/index.ts CHANGED
@@ -37,3 +37,5 @@ export {
37
37
  type RuleEntry,
38
38
  type RuleSet,
39
39
  } from "#rules.ts";
40
+ /** For a `RULE.ts`'s `options`: the executable resolves no `effect` of the repo's own. */
41
+ export { Schema } from "effect";