@drkmttr/adhere 0.21.2 → 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.
- 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 +194 -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 +27 -4
- package/src/create/ask.ts +225 -9
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +70 -19
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +562 -0
- package/src/create/trial.ts +611 -0
- package/src/create/wizard.ts +128 -94
- package/src/init.ts +169 -40
- package/src/install.ts +295 -93
- package/src/instructions.ts +34 -12
- package/src/markdown.ts +12 -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 +5 -2
- package/src/workflows/format.ts +1 -1
package/src/create/ask.ts
CHANGED
|
@@ -5,15 +5,44 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { type Agent, askAgent } from "#create/agents.ts";
|
|
7
7
|
import { spinning } from "#create/look.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { type OxlintCatalog, oxlintCatalog, oxlintRuleIds } from "#oxlint/catalog.ts";
|
|
9
|
+
import { Effect, Schema, SchemaGetter } from "effect";
|
|
9
10
|
import skill from "../../skills/adhere/SKILL.md" with { type: "text" };
|
|
10
11
|
|
|
11
12
|
export class RuleNotCreated extends Schema.TaggedError<RuleNotCreated>()("RuleNotCreated", {
|
|
12
13
|
message: Schema.String,
|
|
13
14
|
}) {}
|
|
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. */
|
|
15
41
|
export const Shape = Schema.Struct({
|
|
16
|
-
linter: Schema.NullOr(
|
|
42
|
+
linter: Schema.NullOr(Linter),
|
|
43
|
+
linterOptions: OptionsFromJson,
|
|
44
|
+
mechanical: Schema.Boolean,
|
|
45
|
+
mechanicalReason: Schema.String,
|
|
17
46
|
scope: Schema.Literals(["project", "org"]),
|
|
18
47
|
scopeReason: Schema.String,
|
|
19
48
|
format: Schema.Literals(["markdown", "typescript"]),
|
|
@@ -35,6 +64,41 @@ const Candidate = Schema.Struct({
|
|
|
35
64
|
export type Candidate = typeof Candidate.Type;
|
|
36
65
|
export const Candidates = Schema.Struct({ candidates: Schema.Array(Candidate) });
|
|
37
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
|
+
|
|
38
102
|
/** Code from the repo the user has judged: where it is, as the file holds it, and whether it breaks the rule. */
|
|
39
103
|
export interface Reviewed {
|
|
40
104
|
readonly where: string;
|
|
@@ -42,16 +106,22 @@ export interface Reviewed {
|
|
|
42
106
|
readonly violation: boolean;
|
|
43
107
|
/** Whether the user's call matched the agent's. */
|
|
44
108
|
readonly agreed: boolean;
|
|
109
|
+
/** What the user added to the call, when they did. */
|
|
110
|
+
readonly note?: string;
|
|
45
111
|
}
|
|
46
112
|
|
|
47
113
|
export const shapePrompt = (
|
|
48
114
|
idea: string,
|
|
115
|
+
catalog: OxlintCatalog = oxlintCatalog,
|
|
49
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.
|
|
50
117
|
|
|
51
118
|
The user wants a rule for: ${idea}
|
|
52
119
|
|
|
53
120
|
Look at this repository as much as you need, then answer:
|
|
54
|
-
- linter:
|
|
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.
|
|
55
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.
|
|
56
126
|
- scopeReason: one sentence saying why.
|
|
57
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.
|
|
@@ -59,7 +129,11 @@ Look at this repository as much as you need, then answer:
|
|
|
59
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.
|
|
60
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.
|
|
61
131
|
- contextReason: one sentence saying why typeAware and reads are as you set them.
|
|
62
|
-
- names: three ids for the rule, each "topic/rule-name" in lowercase kebab case. Reuse a topic from .adhere/rules/ where one fits
|
|
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")}`;
|
|
63
137
|
|
|
64
138
|
export const examplesPrompt = (
|
|
65
139
|
idea: string,
|
|
@@ -75,16 +149,22 @@ const contextOf = (shape: Shape) =>
|
|
|
75
149
|
...(shape.typeAware ? ["`symbols`", "`references`"] : []),
|
|
76
150
|
].join(", ");
|
|
77
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
|
+
|
|
78
158
|
const typescriptFormat = (shape: Shape) =>
|
|
79
|
-
`Write it as a RULE.ts, because ${shape.formatReason} It imports defineRule from "@drkmttr/adhere" and default-exports defineRule({ description, details, must, never, 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.`;
|
|
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.`;
|
|
80
160
|
|
|
81
161
|
const reviewedOf = (reviewed: ReadonlyArray<Reviewed>) =>
|
|
82
162
|
reviewed.length === 0
|
|
83
163
|
? "No code from the repository was judged: write short examples of your own."
|
|
84
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
|
|
85
165
|
.map(
|
|
86
|
-
({ where, code, violation, agreed }) =>
|
|
87
|
-
`${violation ? "Breaks the rule" : "Does not break the rule"}, ${where}${agreed ? "" : " (overruled)"}\n\`\`\`\n${code}\n\`\`\``,
|
|
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\`\`\``,
|
|
88
168
|
)
|
|
89
169
|
.join("\n\n")}`;
|
|
90
170
|
|
|
@@ -102,12 +182,148 @@ Using the reference above, write the rule file for this adhere rule.
|
|
|
102
182
|
Rule id: ${id}
|
|
103
183
|
What it enforces: ${idea}
|
|
104
184
|
What Jev reads beside the rule: ${contextOf(shape)}
|
|
185
|
+
${readsInstruction(shape)}
|
|
105
186
|
${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
|
|
106
187
|
|
|
107
188
|
${reviewedOf(reviewed)}
|
|
108
189
|
|
|
109
190
|
Reply with only the file's contents, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
|
|
110
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
|
+
|
|
111
327
|
/** An agent's reply as the file it holds, without the fence it may wrap it in. */
|
|
112
328
|
export const unfenced = (text: string) =>
|
|
113
329
|
/^\s*```[a-z]*\n([\s\S]*?)\n```\s*$/.exec(text)?.[1] ?? text.trim();
|
|
@@ -122,12 +338,12 @@ export const asking = (agent: Agent, what: string, cwd: string, prompt: string,
|
|
|
122
338
|
}),
|
|
123
339
|
);
|
|
124
340
|
|
|
125
|
-
export const askingFor = <A>(
|
|
341
|
+
export const askingFor = <A, I>(
|
|
126
342
|
agent: Agent,
|
|
127
343
|
what: string,
|
|
128
344
|
cwd: string,
|
|
129
345
|
prompt: string,
|
|
130
|
-
Answer: Schema.Codec<A,
|
|
346
|
+
Answer: Schema.Codec<A, I>,
|
|
131
347
|
) =>
|
|
132
348
|
asking(
|
|
133
349
|
agent,
|
|
@@ -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
|
+
});
|
package/src/create/look.ts
CHANGED
|
@@ -4,37 +4,86 @@
|
|
|
4
4
|
* rail, and a spinner, as `gum spin` draws one, while an agent works.
|
|
5
5
|
*/
|
|
6
6
|
import { highlightedLines } from "#workflows/format.ts";
|
|
7
|
+
import { ruleBodyOf } from "#markdown.ts";
|
|
7
8
|
import { Status, clearingStatus } from "#services/Status.ts";
|
|
8
9
|
import { Console, Duration, Effect, Fiber } from "effect";
|
|
9
10
|
import { Prompt } from "effect/cli";
|
|
10
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
|
+
|
|
11
27
|
/**
|
|
12
|
-
* The prompts' theme. Every
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
15
31
|
*/
|
|
16
32
|
export const look: Partial<Prompt.Theme> = {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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}`,
|
|
20
45
|
arrowUp: "↑ ",
|
|
21
46
|
arrowDown: "↓ ",
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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,
|
|
27
56
|
};
|
|
28
57
|
|
|
29
58
|
const columns = () => process.stderr.columns ?? 80;
|
|
30
59
|
|
|
31
|
-
/** `text` wrapped to the terminal, each further line indented under
|
|
60
|
+
/** `text` wrapped to the terminal, each further line indented under the separator as the first is. */
|
|
32
61
|
export const indented = (text: string) =>
|
|
33
|
-
Bun.wrapAnsi(text, Math.max(columns() -
|
|
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
|
+
});
|
|
34
82
|
|
|
35
83
|
type Color = (theme: Prompt.Theme) => string;
|
|
36
84
|
const muted: Color = (theme) => theme.mutedColor;
|
|
37
85
|
const primary: Color = (theme) => theme.primaryColor;
|
|
86
|
+
const accent: Color = (_theme) => COLORS.PINK;
|
|
38
87
|
|
|
39
88
|
const paint = (theme: Prompt.Theme, text: string, color?: Color) =>
|
|
40
89
|
theme.colors && color !== undefined ? `${color(theme)}${text}\x1b[0m` : text;
|
|
@@ -46,14 +95,16 @@ const paint = (theme: Prompt.Theme, text: string, color?: Color) =>
|
|
|
46
95
|
const rail = (symbol: string, symbolColor: Color, text: string, textColor?: Color, bar = "│") =>
|
|
47
96
|
Effect.gen(function* () {
|
|
48
97
|
const theme = yield* Prompt.Theme;
|
|
49
|
-
const [first = "", ...rest] = Bun.wrapAnsi(text, Math.max(columns() -
|
|
98
|
+
const [first = "", ...rest] = Bun.wrapAnsi(text, Math.max(columns() - 4, 20), {
|
|
50
99
|
hard: true,
|
|
51
100
|
trim: false,
|
|
52
101
|
}).split("\n");
|
|
53
102
|
yield* Console.error(
|
|
54
103
|
[
|
|
55
|
-
`${paint(theme, symbol, symbolColor)} ${paint(theme, first, textColor)}`,
|
|
56
|
-
...rest.map(
|
|
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
|
+
),
|
|
57
108
|
].join("\n"),
|
|
58
109
|
);
|
|
59
110
|
});
|
|
@@ -91,7 +142,7 @@ export const excerpt = (
|
|
|
91
142
|
Effect.gen(function* () {
|
|
92
143
|
const theme = yield* Prompt.Theme;
|
|
93
144
|
const width = String(range.to).length;
|
|
94
|
-
const room = Math.max(columns() - width -
|
|
145
|
+
const room = Math.max(columns() - width - 9, 20);
|
|
95
146
|
const cut = lines.slice(range.from - 1, range.to).map((text) => {
|
|
96
147
|
const flat = text.replaceAll("\t", " ");
|
|
97
148
|
return flat.length > room ? `${flat.slice(0, room - 1)}…` : flat;
|
|
@@ -105,7 +156,7 @@ export const excerpt = (
|
|
|
105
156
|
const body = inside
|
|
106
157
|
? `${paint(theme, number, muted)} ${colored[index] ?? text}`
|
|
107
158
|
: paint(theme, `${number} ${text}`, muted);
|
|
108
|
-
return `${paint(theme, "│", muted)} ${marker} ${body}`;
|
|
159
|
+
return `${PAD}${paint(theme, "│", muted)} ${marker} ${body}`;
|
|
109
160
|
});
|
|
110
161
|
yield* Console.error(rows.join("\n"));
|
|
111
162
|
});
|
|
@@ -127,7 +178,7 @@ export const spinning = <A, E, R>(text: string, effect: Effect.Effect<A, E, R>)
|
|
|
127
178
|
const spinner = yield* Effect.forkChild(
|
|
128
179
|
Effect.suspend(() =>
|
|
129
180
|
status.show(
|
|
130
|
-
`${paint(theme, frames[frame++ % frames.length] ?? "",
|
|
181
|
+
`${PAD}${paint(theme, frames[frame++ % frames.length] ?? "", accent)} ${paint(theme, `${text}…`, muted)}`,
|
|
131
182
|
),
|
|
132
183
|
).pipe(Effect.andThen(Effect.sleep("110 millis")), Effect.forever),
|
|
133
184
|
);
|