@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,363 @@
1
+ /**
2
+ * What `adhere rules create` asks the coding agent, and the answers it
3
+ * decodes: the rule's shape, code in the repo for the user to judge, and the
4
+ * rule file itself.
5
+ */
6
+ import { type Agent, askAgent } from "#create/agents.ts";
7
+ import { spinning } from "#create/look.ts";
8
+ import { type OxlintCatalog, oxlintCatalog, oxlintRuleIds } from "#oxlint/catalog.ts";
9
+ import { Effect, Schema, SchemaGetter } from "effect";
10
+ import skill from "../../skills/adhere/SKILL.md" with { type: "text" };
11
+
12
+ export class RuleNotCreated extends Schema.TaggedError<RuleNotCreated>()("RuleNotCreated", {
13
+ message: Schema.String,
14
+ }) {}
15
+
16
+ const KNOWN_LINTERS = new Set(oxlintRuleIds);
17
+
18
+ /**
19
+ * What a rule's options look like on the wire: one plain JSON string, parsed
20
+ * once here. Not `Schema.fromJsonString`, which marks it with a
21
+ * `contentMediaType` that strict structured outputs reject.
22
+ */
23
+ const OptionsFromJson = Schema.String.pipe(
24
+ Schema.decodeTo(Schema.Array(Schema.Json), {
25
+ decode: SchemaGetter.parseJson() as SchemaGetter.Getter<ReadonlyArray<Schema.Json>, string>,
26
+ encode: SchemaGetter.stringifyJson(),
27
+ }),
28
+ );
29
+
30
+ /**
31
+ * A native rule id, a plain string on the wire: the catalog has more ids than
32
+ * a provider's largest enum takes, so the catalog check runs here instead.
33
+ */
34
+ const Linter = Schema.String.check(
35
+ Schema.makeFilter((id) =>
36
+ KNOWN_LINTERS.has(id) ? undefined : "not an oxlint rule in adhere's catalog",
37
+ ),
38
+ );
39
+
40
+ /** The agent's answer: `linter` and its options are compact on the wire, a decoded pair here. */
41
+ export const Shape = Schema.Struct({
42
+ linter: Schema.NullOr(Linter),
43
+ linterOptions: OptionsFromJson,
44
+ mechanical: Schema.Boolean,
45
+ mechanicalReason: Schema.String,
46
+ scope: Schema.Literals(["project", "org"]),
47
+ scopeReason: Schema.String,
48
+ format: Schema.Literals(["markdown", "typescript"]),
49
+ formatReason: Schema.String,
50
+ typeAware: Schema.Boolean,
51
+ reads: Schema.Array(Schema.Literals(["workspacePackages", "dependents"])),
52
+ contextReason: Schema.String,
53
+ names: Schema.Array(Schema.String),
54
+ });
55
+ export type Shape = typeof Shape.Type;
56
+
57
+ const Candidate = Schema.Struct({
58
+ file: Schema.String,
59
+ startLine: Schema.Int,
60
+ endLine: Schema.Int,
61
+ violation: Schema.Boolean,
62
+ reason: Schema.String,
63
+ });
64
+ export type Candidate = typeof Candidate.Type;
65
+ export const Candidates = Schema.Struct({ candidates: Schema.Array(Candidate) });
66
+
67
+ /**
68
+ * A diagnostic location on the wire: strict structured outputs require every
69
+ * property, so agents send `null` when a location does not matter. Internally
70
+ * `null` decodes to `undefined`, which `JSON.stringify` then omits.
71
+ */
72
+ const Location = Schema.NullOr(Schema.Int).pipe(
73
+ Schema.decodeTo(Schema.UndefinedOr(Schema.Int), {
74
+ decode: SchemaGetter.transform((value: number | null) => value ?? undefined),
75
+ encode: SchemaGetter.transform((value: number | undefined) => value ?? null),
76
+ }),
77
+ );
78
+
79
+ /** One diagnostic an oxlint plugin must report for an invalid example. */
80
+ const PluginError = Schema.Struct({
81
+ message: Schema.String,
82
+ line: Location,
83
+ column: Location,
84
+ });
85
+
86
+ /** An invalid example for an oxlint plugin: code and the diagnostics it must produce. */
87
+ const PluginInvalid = Schema.Struct({
88
+ code: Schema.String,
89
+ errors: Schema.Array(PluginError),
90
+ });
91
+
92
+ /** An agent's oxlint JS plugin rule, and the examples that show it works. */
93
+ export const PluginDraft = Schema.Struct({
94
+ plugin: Schema.String,
95
+ pluginName: Schema.String,
96
+ ruleName: Schema.String,
97
+ valid: Schema.Array(Schema.String),
98
+ invalid: Schema.Array(PluginInvalid),
99
+ });
100
+ export type PluginDraft = typeof PluginDraft.Type;
101
+
102
+ /** Code from the repo the user has judged: where it is, as the file holds it, and whether it breaks the rule. */
103
+ export interface Reviewed {
104
+ readonly where: string;
105
+ readonly code: string;
106
+ readonly violation: boolean;
107
+ /** Whether the user's call matched the agent's. */
108
+ readonly agreed: boolean;
109
+ /** What the user added to the call, when they did. */
110
+ readonly note?: string;
111
+ }
112
+
113
+ export const shapePrompt = (
114
+ idea: string,
115
+ catalog: OxlintCatalog = oxlintCatalog,
116
+ ) => `You are helping write a rule for adhere, a linter for conventions a normal linter cannot check: a model, Jev, judges each source file against each rule. A rule lives in .adhere/rules/<topic>/<name>/, either in this repository, for its own code, or in the organization's shared rules repository, which every repository installs from.
117
+
118
+ The user wants a rule for: ${idea}
119
+
120
+ Look at this repository as much as you need, then answer:
121
+ - linter: a string holding the id of one native oxlint rule listed below that already checks exactly this, or null. Do not name a rule that is not listed.
122
+ - linterOptions: a string holding that rule's own settings as a JSON array, exactly as a config writes them after the severity; for example the string '[{"paths": [{"name": "lodash"}]}]' for no-restricted-imports banning lodash, or '[{"max": 200}]' for max-lines. Use '[]' only when the rule's default settings already enforce this idea; a rule that does nothing until configured (no-restricted-imports, no-restricted-globals, no-restricted-properties, no-restricted-exports, id-length, max-lines, max-params, no-magic-numbers, unicorn/filename-case, typescript/no-restricted-types) always needs its settings here. When linter is null, use '[]'.
123
+ - mechanical: true when a small custom oxlint JS plugin that sees only the syntax tree and its scopes could decide this; false when it needs TypeScript types, the text of other files, a different parser, or anything else oxlint's JS plugins cannot see. When typeAware is true, mechanical must be false.
124
+ - mechanicalReason: one sentence saying why.
125
+ - scope: "project" when the rule is about this repository's own code, "org" when it would hold in any of the organization's repositories.
126
+ - scopeReason: one sentence saying why.
127
+ - format: "markdown" for a RULE.md, the rule's text and examples; nearly every rule is one. "typescript" for a RULE.ts, only when judging a file needs something neither the file nor the shared context below shows, such as a schema or a list another file holds: its appendState hook reads it and adds it to what Jev reads for that rule.
128
+ - formatReason: one sentence saying why.
129
+ - typeAware: true when judging needs the compiler's view of the file: the types of the names it uses, or the declarations of what it uses from other files. It is slower and costs more, so false unless the file cannot show it.
130
+ - reads: the shared context it needs: "workspacePackages", the workspace packages the file imports and their directories, and "dependents", what depends on the file. Empty when the code is enough.
131
+ - contextReason: one sentence saying why typeAware and reads are as you set them.
132
+ - names: three ids for the rule, each "topic/rule-name" in lowercase kebab case. Reuse a topic from .adhere/rules/ where one fits.
133
+
134
+ The catalog below is bundled with adhere and was generated from oxlint ${catalog.version}; the oxlint installed in this repository may be a different version, so a listed rule can be absent there and an installed native rule can be missing from this list. Its native rules follow, one per line as "id — what it checks". Use these ids exactly; a rule not listed here is not one adhere treats as native.
135
+
136
+ ${catalog.rules.map((rule) => `- ${rule.id} — ${rule.summary} (${rule.category}${rule.typeAware ? ", type-aware" : ""})`).join("\n")}`;
137
+
138
+ export const examplesPrompt = (
139
+ idea: string,
140
+ id: string,
141
+ ) => `Find code in this repository to test the adhere rule ${id}: ${idea}
142
+
143
+ Give up to six excerpts, a mix of code that breaks the rule and code that follows it; borderline cases are the most useful. Each is the repo-relative file, the excerpt's first and last line numbers, at most 15 lines apart, violation: whether it breaks the rule, and reason: one sentence saying why. Use only code that exists, and give none when there is none.`;
144
+
145
+ const contextOf = (shape: Shape) =>
146
+ [
147
+ "`code`",
148
+ ...shape.reads.map((read) => `\`${read}\``),
149
+ ...(shape.typeAware ? ["`symbols`", "`references`"] : []),
150
+ ].join(", ");
151
+
152
+ const readsInstruction = (shape: Shape) => {
153
+ const reads = [...shape.reads, ...(shape.typeAware ? ["symbols", "references"] : [])];
154
+ if (reads.length === 0) return "Declare no `reads`: the code alone is enough.";
155
+ return `Declare the context Jev needs beside the code as \`reads: [${reads.map((read) => `"${read}"`).join(", ")}]\` ${shape.format === "typescript" ? "in `defineRule({...})`" : "in its front matter"}. A rule's \`reads\` is a declaration, not an enablement: the project's config still decides what Jev reads, and lint warns that the rule may be less accurate until a declared read is enabled.`;
156
+ };
157
+
158
+ const typescriptFormat = (shape: Shape) =>
159
+ `Write it as a RULE.ts, because ${shape.formatReason} It imports defineRule from "@drkmttr/adhere" and default-exports defineRule({ description, details, must, never, reads, appendState }). appendState(state, file, Bun) runs before each request for the rule: file is { path, contents }, its path absolute, and Bun is Bun's API, as in Bun.file(path).text() or new Bun.Glob(pattern). What it returns is spread over the request's state; name each key it adds in the description, in backticks. Helpers go in the same file.`;
160
+
161
+ const reviewedOf = (reviewed: ReadonlyArray<Reviewed>) =>
162
+ reviewed.length === 0
163
+ ? "No code from the repository was judged: write short examples of your own."
164
+ : `The user judged this code from the repository. Base the rule's examples on what shows it best; you need not use all of it. Where the user overruled your first call, word the rule so the difference is clear.\n\n${reviewed
165
+ .map(
166
+ ({ where, code, violation, agreed, note }) =>
167
+ `${violation ? "Breaks the rule" : "Does not break the rule"}, ${where}${agreed ? "" : " (overruled)"}${note === undefined ? "" : `\nThe user's note: ${note}`}\n\`\`\`\n${code}\n\`\`\``,
168
+ )
169
+ .join("\n\n")}`;
170
+
171
+ export const writePrompt = (
172
+ idea: string,
173
+ id: string,
174
+ shape: Shape,
175
+ reviewed: ReadonlyArray<Reviewed>,
176
+ ) => `${skill}
177
+
178
+ ---
179
+
180
+ Using the reference above, write the rule file for this adhere rule.
181
+
182
+ Rule id: ${id}
183
+ What it enforces: ${idea}
184
+ What Jev reads beside the rule: ${contextOf(shape)}
185
+ ${readsInstruction(shape)}
186
+ ${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
187
+
188
+ ${reviewedOf(reviewed)}
189
+
190
+ Reply with only the file's contents, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
191
+
192
+ /**
193
+ * Asks for an oxlint JS plugin rule. The reply is a self-contained ESLint v9
194
+ * plain-ESM plugin: oxlint loads it as written, so no TypeScript, imports, or
195
+ * build-tool helpers.
196
+ */
197
+ export const pluginPrompt = (
198
+ idea: string,
199
+ id: string,
200
+ reviewed: ReadonlyArray<Reviewed>,
201
+ ) => `Write one rule for oxlint as a JS plugin. The rule is called ${id}; it enforces: ${idea}
202
+
203
+ Oxlint's JS plugin API is ESLint v9's. Write a plain ESM module:
204
+ - No TypeScript, no imports, no Vite+ or build-tool helpers, no dependencies: oxlint loads the file as it is.
205
+ - Default-export { meta: { name: "<pluginName>" }, rules: { "<ruleName>": rule } }.
206
+ - The rule is { meta: { schema: [], messages: {...} }, create(context) { return { SomeNode(node) { ... } }; } }.
207
+ - Report with context.report({ message: "a literal message", node }) so the expected diagnostic is exact. Do not use messageId.
208
+ - Judge only the syntax tree and scopes. Oxlint JS plugins cannot see TypeScript types, other files' contents, or another parser's AST; do not reach for them.
209
+ - pluginName and ruleName are lowercase kebab case. pluginName must not be one oxlint has natively, such as eslint, typescript, react, import, jsdoc, jest, vitest, unicorn, jsx-a11y, nextjs, node, promise, vue, oxc, or react-perf.
210
+ - Give at least two valid snippets that must report nothing, and at least two invalid snippets. Each invalid snippet carries every diagnostic it must produce: message exactly as context.report writes it; line (1-based) and column (0-based) are always present: use null when a diagnostic's position does not matter.
211
+
212
+ ${reviewedOf(reviewed)}
213
+
214
+ Answer with JSON: plugin (the module's whole source), pluginName, ruleName, valid (code strings), invalid (objects with code and errors, each { message, line, column }, with line and column null when unspecified).`;
215
+
216
+ /** What to send back to the agent once its plugin or examples fail oxlint's RuleTester. */
217
+ export const pluginFixPrompt = (
218
+ prompt: string,
219
+ draft: PluginDraft,
220
+ problems: ReadonlyArray<string>,
221
+ ) => `${prompt}
222
+
223
+ Your last answer failed its own tests:
224
+ ${problems.map((problem) => `- ${problem}`).join("\n")}
225
+
226
+ The plugin was:
227
+
228
+ \`\`\`js
229
+ ${draft.plugin}
230
+ \`\`\`
231
+
232
+ The examples were:
233
+ ${draft.valid.map((code, index) => `Valid ${index + 1}:\n\`\`\`\n${code}\n\`\`\``).join("\n")}
234
+ ${draft.invalid
235
+ .map(
236
+ (example, index) =>
237
+ `Invalid ${index + 1}:\n\`\`\`\n${example.code}\n\`\`\`\nExpected: ${JSON.stringify(example.errors.map((error) => ({ message: error.message, line: error.line ?? null, column: error.column ?? null })))}`,
238
+ )
239
+ .join("\n")}
240
+
241
+ Fix the rule or the examples so every valid example reports nothing and every invalid example reports exactly the diagnostics it expects. Reply with only the corrected JSON.`;
242
+
243
+ /** The agent's revised native settings, as the wire holds them: a JSON array in a string. */
244
+ export const NativeRevision = Schema.Struct({ options: OptionsFromJson });
245
+ export type NativeRevision = typeof NativeRevision.Type;
246
+
247
+ /**
248
+ * What the trial found, as the agent reads it: the saved report's path, the
249
+ * files it judged, and what the user made of each finding.
250
+ */
251
+ export const inspectionPrompt = (report: string, id: string) =>
252
+ `adhere trialled the rule ${id} on real files without installing it. The full report is at ${report}. Read it, and read the source files it names, then answer in plain text:
253
+ - Whether the rule catches what it should and what it should not, citing the report's findings.
254
+ - Which findings look like false positives or false negatives, and why.
255
+ - Concrete changes to the rule's wording or examples, if any.
256
+ Do not edit files. Keep it short.`;
257
+
258
+ /** Asks for the adhere rule again, with what the trial found and what the user made of it. */
259
+ export const adhereRevisionPrompt = (
260
+ idea: string,
261
+ id: string,
262
+ shape: Shape,
263
+ draft: string,
264
+ evidence: string,
265
+ ) => `${skill}
266
+
267
+ ---
268
+
269
+ Revise the adhere rule ${id}. It enforces: ${idea}
270
+ What Jev reads beside the rule: ${contextOf(shape)}
271
+ ${readsInstruction(shape)}
272
+ ${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
273
+
274
+ A trial judged real files against the current rule. What it found, and what the user made of it:
275
+
276
+ ${evidence}
277
+
278
+ The current file:
279
+
280
+ ${draft}
281
+
282
+ Keep what the evidence supports and change what it contradicts. Reply with only the corrected file, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
283
+
284
+ /** Asks for the oxlint plugin again, with what the trial found and what the user made of it. */
285
+ export const pluginRevisionPrompt = (
286
+ idea: string,
287
+ id: string,
288
+ draft: PluginDraft,
289
+ evidence: string,
290
+ ) => `Revise the oxlint JS plugin rule ${id}; it enforces: ${idea}
291
+
292
+ A trial ran the current plugin on real files. What it found, and what the user made of it:
293
+
294
+ ${evidence}
295
+
296
+ The current plugin:
297
+
298
+ \`\`\`js
299
+ ${draft.plugin}
300
+ \`\`\`
301
+
302
+ Its examples were:
303
+ ${draft.valid.map((code, index) => `Valid ${index + 1}:\n\`\`\`\n${code}\n\`\`\``).join("\n")}
304
+ ${draft.invalid
305
+ .map(
306
+ (example, index) =>
307
+ `Invalid ${index + 1}:\n\`\`\`\n${example.code}\n\`\`\`\nExpected: ${JSON.stringify(example.errors.map((error) => ({ message: error.message, line: error.line ?? null, column: error.column ?? null })))}`,
308
+ )
309
+ .join("\n")}
310
+
311
+ Keep the API rules: plain ESM, no imports, default-export { meta, rules }, and report with context.report({ message, node }). Reply with only JSON: plugin, pluginName, ruleName, valid, invalid, as before.`;
312
+
313
+ /** Asks for the native rule's settings again, with what the trial found and what the user made of it. */
314
+ export const nativeRevisionPrompt = (
315
+ idea: string,
316
+ id: string,
317
+ options: ReadonlyArray<unknown>,
318
+ evidence: string,
319
+ ) => `Revise the settings for the oxlint rule ${id}; it should enforce: ${idea}
320
+
321
+ A trial ran it on real files with the settings ${JSON.stringify(options)}. What it found, and what the user made of it:
322
+
323
+ ${evidence}
324
+
325
+ Answer with JSON: options, a string holding the rule's settings as a JSON array, exactly as a config writes them after the severity. Use "[]" only when the rule's default settings enforce the idea.`;
326
+
327
+ /** An agent's reply as the file it holds, without the fence it may wrap it in. */
328
+ export const unfenced = (text: string) =>
329
+ /^\s*```[a-z]*\n([\s\S]*?)\n```\s*$/.exec(text)?.[1] ?? text.trim();
330
+
331
+ export const asking = (agent: Agent, what: string, cwd: string, prompt: string, schema?: object) =>
332
+ spinning(
333
+ `Asking ${agent} ${what}`,
334
+ Effect.tryPromise({
335
+ try: () => askAgent(agent, prompt, cwd, schema),
336
+ catch: (cause) =>
337
+ new RuleNotCreated({ message: cause instanceof Error ? cause.message : String(cause) }),
338
+ }),
339
+ );
340
+
341
+ export const askingFor = <A, I>(
342
+ agent: Agent,
343
+ what: string,
344
+ cwd: string,
345
+ prompt: string,
346
+ Answer: Schema.Codec<A, I>,
347
+ ) =>
348
+ asking(
349
+ agent,
350
+ what,
351
+ cwd,
352
+ prompt,
353
+ Schema.toJsonSchemaDocument(Answer, { onExcessProperty: "error" }).schema,
354
+ ).pipe(
355
+ Effect.flatMap((text) =>
356
+ Schema.decodeUnknownEffect(Schema.fromJsonString(Answer))(text).pipe(
357
+ Effect.mapError(
358
+ (problem) =>
359
+ new RuleNotCreated({ message: `${agent}'s answer did not decode: ${problem.message}` }),
360
+ ),
361
+ ),
362
+ ),
363
+ );
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Code from the repository that shows a rule, read from the file itself and
3
+ * put to the user, who says whether each excerpt breaks the rule.
4
+ */
5
+ import { type Agent } from "#create/agents.ts";
6
+ import {
7
+ type Candidate,
8
+ Candidates,
9
+ type Reviewed,
10
+ askingFor,
11
+ examplesPrompt,
12
+ } from "#create/ask.ts";
13
+ import { excerpt, line, note, said, warn } from "#create/look.ts";
14
+ import { Effect, FileSystem, Option, Path } from "effect";
15
+ import { Prompt } from "effect/cli";
16
+
17
+ /**
18
+ * Shows the user each excerpt the agent found, read from the file itself with
19
+ * three lines either side, and asks whether it breaks the rule. An excerpt
20
+ * that does not match the repo is left out.
21
+ */
22
+ export const review = (agent: Agent, root: string, candidates: ReadonlyArray<Candidate>) =>
23
+ Effect.gen(function* () {
24
+ const fs = yield* FileSystem.FileSystem;
25
+ const path = yield* Path.Path;
26
+ const reviewed: Array<Reviewed> = [];
27
+ let missing = 0;
28
+ for (const [index, candidate] of candidates.entries()) {
29
+ const file = path.resolve(root, candidate.file);
30
+ const where = path.relative(root, file);
31
+ const text = where.startsWith("..")
32
+ ? Option.none()
33
+ : yield* fs.readFileString(file).pipe(Effect.option);
34
+ const lines = Option.getOrElse(text, () => "").split("\n");
35
+ const start = candidate.startLine;
36
+ if (Option.isNone(text) || start < 1 || start > lines.length) {
37
+ missing++;
38
+ continue;
39
+ }
40
+ const end = Math.min(Math.max(candidate.endLine, start), start + 14, lines.length);
41
+ const at = where + ":" + (start === end ? start : start + "-" + end);
42
+ yield* line("");
43
+ yield* line(at + " (" + (index + 1) + " of " + candidates.length + ")");
44
+ yield* excerpt(lines, {
45
+ from: Math.max(1, start - 3),
46
+ to: Math.min(lines.length, end + 3),
47
+ start,
48
+ end,
49
+ });
50
+ yield* said(
51
+ agent + ": " + (candidate.violation ? "breaks it" : "follows it") + ". " + candidate.reason,
52
+ );
53
+ const violation = yield* Prompt.run(
54
+ Prompt.Confirm({ message: "Is this a violation?", initial: candidate.violation }),
55
+ );
56
+ const note = (yield* Prompt.run(
57
+ Prompt.String({ message: "Note for the agent (optional)", default: "" }),
58
+ )).trim();
59
+ reviewed.push({
60
+ where: at,
61
+ code: lines.slice(start - 1, end).join("\n"),
62
+ violation,
63
+ agreed: violation === candidate.violation,
64
+ ...(note === "" ? {} : { note }),
65
+ });
66
+ }
67
+ if (missing > 0) {
68
+ yield* note(
69
+ "Left out " + missing + " excerpt" + (missing === 1 ? "" : "s") + " not found in the repo.",
70
+ );
71
+ }
72
+ return reviewed;
73
+ });
74
+
75
+ /**
76
+ * Asks the agent for excerpts in the project the user is in, then puts them to
77
+ * the user. An agent that cannot search the repo leaves the examples to the
78
+ * rule's draft.
79
+ */
80
+ export const reviewedInRepository = (agent: Agent, root: string, idea: string, id: string) =>
81
+ Effect.gen(function* () {
82
+ const found = yield* askingFor(
83
+ agent,
84
+ "for code to judge",
85
+ root,
86
+ examplesPrompt(idea, id),
87
+ Candidates,
88
+ ).pipe(
89
+ Effect.catch(({ message }) =>
90
+ warn("No code from the repo: " + message).pipe(Effect.as({ candidates: [] })),
91
+ ),
92
+ );
93
+ return yield* review(agent, root, found.candidates);
94
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * How `adhere rules create` looks: Clack's rail, drawn by effect/cli's own
3
+ * prompts through their theme, with the wizard's own lines hung on the same
4
+ * rail, and a spinner, as `gum spin` draws one, while an agent works.
5
+ */
6
+ import { highlightedLines } from "#workflows/format.ts";
7
+ import { ruleBodyOf } from "#markdown.ts";
8
+ import { Status, clearingStatus } from "#services/Status.ts";
9
+ import { Console, Duration, Effect, Fiber } from "effect";
10
+ import { Prompt } from "effect/cli";
11
+
12
+ const rgbColor = (r: number, g: number, b: number) => `\x1B[38;2;${r};${g};${b}m`;
13
+ const colorCode = (n: number) => `\x1B[38;5;${n}m`;
14
+ const COLORS = {
15
+ PURPLE: colorCode(99),
16
+ KIWI: colorCode(156),
17
+ PINK: colorCode(212),
18
+ FG: rgbColor(220, 220, 240),
19
+ MUTED: rgbColor(180, 180, 190),
20
+ RED: colorCode(197),
21
+ ACCENT: rgbColor(242, 205, 205),
22
+ };
23
+
24
+ /** One column of left padding, so the whole wizard sits clear of the terminal's edge. */
25
+ const PAD = " ";
26
+
27
+ /**
28
+ * The prompts' theme. Every symbol carries `PAD`, so each prompt's rows start
29
+ * where the rail's do; `pointerSmall` also pads a validation error and adds
30
+ * one space before the `›` that ends an active row.
31
+ */
32
+ export const look: Partial<Prompt.Theme> = {
33
+ // primaryColor: `\x1B[0;37m`,
34
+ // mutedColor: COLORS.MUTED,
35
+ successColor: COLORS.KIWI,
36
+ submittedColor: COLORS.PURPLE,
37
+ errorColor: COLORS.RED,
38
+ // toggleSeparator: " / ",
39
+ // passwordMask: `${COLORS.MUTED}*${ESC}`,
40
+ // prefix: `│\n│${COLORS.PINK} ◇${ESC}`,
41
+ // prefix: `\n ◇`,
42
+ // tick: `\n ✓`,
43
+ // tick: `│\n│${COLORS.KIWI} ✓${ESC}`,
44
+ // pointer: `${COLORS.PURPLE}‣${ESC}`,
45
+ arrowUp: "↑ ",
46
+ arrowDown: "↓ ",
47
+ prefix: `${PAD}?`,
48
+ pointer: `${PAD}❯`,
49
+ pointerSmall: `${PAD}›`,
50
+ ellipsis: "…",
51
+ tick: `${PAD}✔`,
52
+ descriptionSeparator: "\n\n - ",
53
+ checkboxOn: "󰄲",
54
+ checkboxOff: "",
55
+ colors: true,
56
+ };
57
+
58
+ const columns = () => process.stderr.columns ?? 80;
59
+
60
+ /** `text` wrapped to the terminal, each further line indented under the separator as the first is. */
61
+ export const indented = (text: string) =>
62
+ Bun.wrapAnsi(text, Math.max(columns() - 5, 20)).replaceAll("\n", "\n ");
63
+
64
+ /**
65
+ * Renders the body of a rule for preview, preserving its YAML frontmatter.
66
+ * The frontmatter is shown as-is so its metadata stays visible; the body is
67
+ * rendered with Bun's native Markdown-to-ANSI renderer.
68
+ */
69
+ export const renderMarkdown = (text: string) =>
70
+ Effect.gen(function* () {
71
+ const theme = yield* Prompt.Theme;
72
+ const body = ruleBodyOf(text);
73
+ const prefix = text.slice(0, text.length - body.length);
74
+ const rendered = Bun.markdown
75
+ .ansi(body, {
76
+ colors: theme.colors,
77
+ columns: Math.max(columns() - 4, 20),
78
+ })
79
+ .trimEnd();
80
+ return prefix + rendered;
81
+ });
82
+
83
+ type Color = (theme: Prompt.Theme) => string;
84
+ const muted: Color = (theme) => theme.mutedColor;
85
+ const primary: Color = (theme) => theme.primaryColor;
86
+ const accent: Color = (_theme) => COLORS.PINK;
87
+
88
+ const paint = (theme: Prompt.Theme, text: string, color?: Color) =>
89
+ theme.colors && color !== undefined ? `${color(theme)}${text}\x1b[0m` : text;
90
+
91
+ /**
92
+ * Prints `text` beside `symbol`, wrapped to the terminal, its further lines
93
+ * beside `bar`, in color when the theme has colors.
94
+ */
95
+ const rail = (symbol: string, symbolColor: Color, text: string, textColor?: Color, bar = "│") =>
96
+ Effect.gen(function* () {
97
+ const theme = yield* Prompt.Theme;
98
+ const [first = "", ...rest] = Bun.wrapAnsi(text, Math.max(columns() - 4, 20), {
99
+ hard: true,
100
+ trim: false,
101
+ }).split("\n");
102
+ yield* Console.error(
103
+ [
104
+ `${PAD}${paint(theme, symbol, symbolColor)} ${paint(theme, first, textColor)}`,
105
+ ...rest.map(
106
+ (line) => `${PAD}${paint(theme, bar, muted)} ${paint(theme, line, textColor)}`,
107
+ ),
108
+ ].join("\n"),
109
+ );
110
+ });
111
+
112
+ export const intro = (text: string) => rail("┌", muted, text);
113
+ export const line = (text: string) => rail("│", muted, text);
114
+ export const note = (text: string) => rail("│", muted, text, muted);
115
+ export const warn = (text: string) => rail("▲", () => "\x1b[33m", text);
116
+ export const outro = (text: string) =>
117
+ rail("└", (theme) => theme.successColor, text, undefined, " ");
118
+
119
+ /** A choice the agent made, not the user: what it chose, and why, muted beneath. */
120
+ export const decided = (what: string, why: string) =>
121
+ rail("◇", (theme) => theme.successColor, what).pipe(Effect.andThen(note(why)));
122
+
123
+ /** What the agent says about something it shows the user, accented and in italics. */
124
+ export const said = (text: string) =>
125
+ rail("│", muted, text, (theme) => `${theme.primaryColor}\x1b[3m`);
126
+
127
+ /**
128
+ * Prints lines `from` to `to` of a file's `lines`, numbered from 1, on the
129
+ * rail: the excerpt's lines `start` to `end` marked and highlighted as a
130
+ * report highlights code, and the rest muted. A line too long for the
131
+ * terminal is cut.
132
+ */
133
+ export const excerpt = (
134
+ lines: ReadonlyArray<string>,
135
+ range: {
136
+ readonly from: number;
137
+ readonly to: number;
138
+ readonly start: number;
139
+ readonly end: number;
140
+ },
141
+ ) =>
142
+ Effect.gen(function* () {
143
+ const theme = yield* Prompt.Theme;
144
+ const width = String(range.to).length;
145
+ const room = Math.max(columns() - width - 9, 20);
146
+ const cut = lines.slice(range.from - 1, range.to).map((text) => {
147
+ const flat = text.replaceAll("\t", " ");
148
+ return flat.length > room ? `${flat.slice(0, room - 1)}…` : flat;
149
+ });
150
+ // Highlighted together, so a comment or template opened above an excerpt line colors it.
151
+ const colored = highlightedLines(cut.join("\n"), theme.colors);
152
+ const rows = cut.map((text, index) => {
153
+ const number = String(range.from + index).padStart(width);
154
+ const inside = range.from + index >= range.start && range.from + index <= range.end;
155
+ const marker = inside ? paint(theme, "▌", primary) : " ";
156
+ const body = inside
157
+ ? `${paint(theme, number, muted)} ${colored[index] ?? text}`
158
+ : paint(theme, `${number} ${text}`, muted);
159
+ return `${PAD}${paint(theme, "│", muted)} ${marker} ${body}`;
160
+ });
161
+ yield* Console.error(rows.join("\n"));
162
+ });
163
+
164
+ const frames = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
165
+
166
+ /**
167
+ * Runs `effect` beside a spinner and `text` on the status line, then leaves
168
+ * `text` on the rail with how long it took. Where the status line shows
169
+ * nothing, as off a terminal, only that last line is printed.
170
+ */
171
+ export const spinning = <A, E, R>(text: string, effect: Effect.Effect<A, E, R>) =>
172
+ Effect.gen(function* () {
173
+ const theme = yield* Prompt.Theme;
174
+ const status = yield* Status;
175
+ let frame = 0;
176
+ yield* line("");
177
+ // The status line redraws at most every 100 ms.
178
+ const spinner = yield* Effect.forkChild(
179
+ Effect.suspend(() =>
180
+ status.show(
181
+ `${PAD}${paint(theme, frames[frame++ % frames.length] ?? "", accent)} ${paint(theme, `${text}…`, muted)}`,
182
+ ),
183
+ ).pipe(Effect.andThen(Effect.sleep("110 millis")), Effect.forever),
184
+ );
185
+ const [took, result] = yield* Effect.timed(effect).pipe(
186
+ Effect.ensuring(Fiber.interrupt(spinner)),
187
+ clearingStatus,
188
+ );
189
+ yield* note(`${text} · ${Math.max(1, Math.round(Duration.toSeconds(took)))}s`);
190
+ return result;
191
+ });