@drkmttr/adhere 0.21.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1552
- package/package.json +10 -7
- package/presets/README.md +9 -1
- package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
- package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
- package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
- package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
- package/skills/adhere/SKILL.md +214 -30
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +49 -16
- package/src/cli.ts +216 -25
- package/src/config.ts +73 -4
- package/src/create/ask.ts +255 -9
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +62 -19
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +625 -0
- package/src/create/trial.ts +663 -0
- package/src/create/wizard.ts +131 -95
- package/src/filters.ts +86 -0
- package/src/init.ts +169 -40
- package/src/install.ts +295 -93
- package/src/instructions.ts +34 -12
- package/src/markdown.ts +21 -2
- package/src/oxlint/catalog.json +7844 -0
- package/src/oxlint/catalog.ts +72 -0
- package/src/oxlint/shared.ts +140 -0
- package/src/readme-guide.ts +431 -0
- package/src/readme.ts +291 -0
- package/src/services/AdhereConfig.ts +36 -3
- package/src/services/AuditCache.ts +176 -169
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +3 -3
- package/src/workflows/audit.ts +40 -7
- package/src/workflows/format.ts +1 -1
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,33 @@ 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
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What a rule may do to narrow the files it judges at all: a mechanical
|
|
160
|
+
* filter, taught when a rule is written or revised, never to an oxlint rule.
|
|
161
|
+
*/
|
|
162
|
+
const filtersInstruction = (shape: Shape) =>
|
|
163
|
+
`A rule may also narrow the files it judges before Jev sees them, with optional \`include\` and \`exclude\` objects. Each holds \`paths\`, globs relative to the repository root with forward slashes, matched as \`--filter\` matches them, and \`content\`, JavaScript regular expression sources without / delimiters or flags, tested against each file's original text, comments included. Any entry in a list suffices; \`include\` applies only when every list it sets matches, while a match in either \`exclude\` list drops the file. An absent object or an empty list restricts nothing. A filter is silent: a file it drops is never judged, so a wrong one is a false negative. Use \`paths\` for where the rule applies and \`content\` for what a file says; a filter is not a substitute for \`appliesTo\` or \`excludeIf\`, which ask Jev about the code. \`content\` can match a named package's import specifier, such as from "effect", or a directive, but a regular expression cannot resolve aliases or re-exports, know whether a name is a type or a value, or reliably tell code from comments and other strings; use \`paths\`, \`appliesTo\`, or a TypeScript rule for that. Because \`content\` entries are strings, escape a regex's backslashes: a regex ^\\s*import is written "^\\\\s*import" in both front matter JSON and a RULE.ts string. ${
|
|
164
|
+
shape.format === "typescript"
|
|
165
|
+
? 'Set them in defineRule({...}) as object literals, such as include: { paths: ["src/**"] }.'
|
|
166
|
+
: 'Set them in the front matter as one-line JSON objects, such as include: {"paths": ["src/**"]}.'
|
|
167
|
+
}`;
|
|
168
|
+
|
|
78
169
|
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.`;
|
|
170
|
+
`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
171
|
|
|
81
172
|
const reviewedOf = (reviewed: ReadonlyArray<Reviewed>) =>
|
|
82
173
|
reviewed.length === 0
|
|
83
174
|
? "No code from the repository was judged: write short examples of your own."
|
|
84
175
|
: `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
176
|
.map(
|
|
86
|
-
({ where, code, violation, agreed }) =>
|
|
87
|
-
`${violation ? "Breaks the rule" : "Does not break the rule"}, ${where}${agreed ? "" : " (overruled)"}\n\`\`\`\n${code}\n\`\`\``,
|
|
177
|
+
({ where, code, violation, agreed, note }) =>
|
|
178
|
+
`${violation ? "Breaks the rule" : "Does not break the rule"}, ${where}${agreed ? "" : " (overruled)"}${note === undefined ? "" : `\nThe user's note: ${note}`}\n\`\`\`\n${code}\n\`\`\``,
|
|
88
179
|
)
|
|
89
180
|
.join("\n\n")}`;
|
|
90
181
|
|
|
@@ -102,12 +193,167 @@ Using the reference above, write the rule file for this adhere rule.
|
|
|
102
193
|
Rule id: ${id}
|
|
103
194
|
What it enforces: ${idea}
|
|
104
195
|
What Jev reads beside the rule: ${contextOf(shape)}
|
|
196
|
+
${readsInstruction(shape)}
|
|
197
|
+
${filtersInstruction(shape)}
|
|
198
|
+
Leave them out unless the idea itself names a kind of file, such as API routes or React components; the trial will settle whether they are needed.
|
|
105
199
|
${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
|
|
106
200
|
|
|
107
201
|
${reviewedOf(reviewed)}
|
|
108
202
|
|
|
109
203
|
Reply with only the file's contents, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
|
|
110
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Asks for an oxlint JS plugin rule. The reply is a self-contained ESLint v9
|
|
207
|
+
* plain-ESM plugin: oxlint loads it as written, so no TypeScript, imports, or
|
|
208
|
+
* build-tool helpers.
|
|
209
|
+
*/
|
|
210
|
+
export const pluginPrompt = (
|
|
211
|
+
idea: string,
|
|
212
|
+
id: string,
|
|
213
|
+
reviewed: ReadonlyArray<Reviewed>,
|
|
214
|
+
) => `Write one rule for oxlint as a JS plugin. The rule is called ${id}; it enforces: ${idea}
|
|
215
|
+
|
|
216
|
+
Oxlint's JS plugin API is ESLint v9's. Write a plain ESM module:
|
|
217
|
+
- No TypeScript, no imports, no Vite+ or build-tool helpers, no dependencies: oxlint loads the file as it is.
|
|
218
|
+
- Default-export { meta: { name: "<pluginName>" }, rules: { "<ruleName>": rule } }.
|
|
219
|
+
- The rule is { meta: { schema: [], messages: {...} }, create(context) { return { SomeNode(node) { ... } }; } }.
|
|
220
|
+
- Report with context.report({ message: "a literal message", node }) so the expected diagnostic is exact. Do not use messageId.
|
|
221
|
+
- 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.
|
|
222
|
+
- 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.
|
|
223
|
+
- 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.
|
|
224
|
+
|
|
225
|
+
${reviewedOf(reviewed)}
|
|
226
|
+
|
|
227
|
+
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).`;
|
|
228
|
+
|
|
229
|
+
/** What to send back to the agent once its plugin or examples fail oxlint's RuleTester. */
|
|
230
|
+
export const pluginFixPrompt = (
|
|
231
|
+
prompt: string,
|
|
232
|
+
draft: PluginDraft,
|
|
233
|
+
problems: ReadonlyArray<string>,
|
|
234
|
+
) => `${prompt}
|
|
235
|
+
|
|
236
|
+
Your last answer failed its own tests:
|
|
237
|
+
${problems.map((problem) => `- ${problem}`).join("\n")}
|
|
238
|
+
|
|
239
|
+
The plugin was:
|
|
240
|
+
|
|
241
|
+
\`\`\`js
|
|
242
|
+
${draft.plugin}
|
|
243
|
+
\`\`\`
|
|
244
|
+
|
|
245
|
+
The examples were:
|
|
246
|
+
${draft.valid.map((code, index) => `Valid ${index + 1}:\n\`\`\`\n${code}\n\`\`\``).join("\n")}
|
|
247
|
+
${draft.invalid
|
|
248
|
+
.map(
|
|
249
|
+
(example, index) =>
|
|
250
|
+
`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 })))}`,
|
|
251
|
+
)
|
|
252
|
+
.join("\n")}
|
|
253
|
+
|
|
254
|
+
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.`;
|
|
255
|
+
|
|
256
|
+
/** The agent's revised native settings, as the wire holds them: a JSON array in a string. */
|
|
257
|
+
export const NativeRevision = Schema.Struct({ options: OptionsFromJson });
|
|
258
|
+
export type NativeRevision = typeof NativeRevision.Type;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* What the trial found, as the agent reads it: the saved report's path, the
|
|
262
|
+
* files it judged, and what the user made of each finding.
|
|
263
|
+
*/
|
|
264
|
+
export const inspectionPrompt = (report: string, id: string) =>
|
|
265
|
+
`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:
|
|
266
|
+
- Whether the rule catches what it should and what it should not, citing the report's findings.
|
|
267
|
+
- Which findings look like false positives or false negatives, and why.
|
|
268
|
+
- Concrete changes to the rule's wording or examples, if any.
|
|
269
|
+
Do not edit files. Keep it short.`;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The first trial of an adhere rule asks the authoring agent, explicitly,
|
|
273
|
+
* whether the rule should narrow which files it judges. A filter is a silent
|
|
274
|
+
* false negative, so the agent names exact entries or says why none fit.
|
|
275
|
+
*/
|
|
276
|
+
export const filterAdvicePrompt = (report: string, id: string) =>
|
|
277
|
+
`adhere trialled the adhere rule ${id} on real files without installing it. The full report is at ${report}, and it holds the rule's source. Read the report, the rule, and the files it names, then answer in plain text:
|
|
278
|
+
- Whether the rule should carry \`include\` or \`exclude\` filters at all.
|
|
279
|
+
- If it should, the exact entries, as the rule's file writes them: in a RULE.md's front matter, one-line JSON objects such as include: {"paths": ["src/**"]}; in a RULE.ts, object literals such as include: { paths: ["src/**"] }. Say which list each entry belongs to and what it leaves out or keeps.
|
|
280
|
+
- If it should not, one sentence saying why the rule is better judging every file Jev reads.
|
|
281
|
+
|
|
282
|
+
Filters are mechanical and silent: a file one leaves out is never judged, so a wrong filter hides real violations. \`paths\` are repository-root-relative globs with forward slashes, matched as \`--filter\` matches them. \`content\` entries are JavaScript regex source strings without / delimiters or flags, tested against original text including comments. Content filters may match a named package's import specifier, for example from "effect". They cannot resolve aliases or re-exports, and matching text in comments or strings does not prove the file imports the package. Escape regex backslashes in JSON and TypeScript strings: the regex ^\\s*import is written "^\\\\s*import". Within each list, any entry may match; all nonempty inclusion lists must match, and a match in either exclusion list vetoes the file. Empty lists impose no restriction. Recommend a filter only when the evidence supports that boundary.
|
|
283
|
+
|
|
284
|
+
Do not suggest filters for an oxlint rule: those have no such fields. Do not edit files. Keep it short.`;
|
|
285
|
+
|
|
286
|
+
/** Asks for the adhere rule again, with what the trial found and what the user made of it. */
|
|
287
|
+
export const adhereRevisionPrompt = (
|
|
288
|
+
idea: string,
|
|
289
|
+
id: string,
|
|
290
|
+
shape: Shape,
|
|
291
|
+
draft: string,
|
|
292
|
+
evidence: string,
|
|
293
|
+
) => `${skill}
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
Revise the adhere rule ${id}. It enforces: ${idea}
|
|
298
|
+
What Jev reads beside the rule: ${contextOf(shape)}
|
|
299
|
+
${readsInstruction(shape)}
|
|
300
|
+
${filtersInstruction(shape)}
|
|
301
|
+
Apply, change, or remove \`include\` and \`exclude\` as the trial, the recommendation, and the user's verdicts support; keep a filter only while the evidence supports it.
|
|
302
|
+
${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
|
|
303
|
+
|
|
304
|
+
A trial judged real files against the current rule. What it found, and what the user made of it:
|
|
305
|
+
|
|
306
|
+
${evidence}
|
|
307
|
+
|
|
308
|
+
The current file:
|
|
309
|
+
|
|
310
|
+
${draft}
|
|
311
|
+
|
|
312
|
+
Keep what the evidence supports and change what it contradicts, including its filter recommendation. Reply with only the corrected file, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
|
|
313
|
+
|
|
314
|
+
/** Asks for the oxlint plugin again, with what the trial found and what the user made of it. */
|
|
315
|
+
export const pluginRevisionPrompt = (
|
|
316
|
+
idea: string,
|
|
317
|
+
id: string,
|
|
318
|
+
draft: PluginDraft,
|
|
319
|
+
evidence: string,
|
|
320
|
+
) => `Revise the oxlint JS plugin rule ${id}; it enforces: ${idea}
|
|
321
|
+
|
|
322
|
+
A trial ran the current plugin on real files. What it found, and what the user made of it:
|
|
323
|
+
|
|
324
|
+
${evidence}
|
|
325
|
+
|
|
326
|
+
The current plugin:
|
|
327
|
+
|
|
328
|
+
\`\`\`js
|
|
329
|
+
${draft.plugin}
|
|
330
|
+
\`\`\`
|
|
331
|
+
|
|
332
|
+
Its examples were:
|
|
333
|
+
${draft.valid.map((code, index) => `Valid ${index + 1}:\n\`\`\`\n${code}\n\`\`\``).join("\n")}
|
|
334
|
+
${draft.invalid
|
|
335
|
+
.map(
|
|
336
|
+
(example, index) =>
|
|
337
|
+
`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 })))}`,
|
|
338
|
+
)
|
|
339
|
+
.join("\n")}
|
|
340
|
+
|
|
341
|
+
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.`;
|
|
342
|
+
|
|
343
|
+
/** Asks for the native rule's settings again, with what the trial found and what the user made of it. */
|
|
344
|
+
export const nativeRevisionPrompt = (
|
|
345
|
+
idea: string,
|
|
346
|
+
id: string,
|
|
347
|
+
options: ReadonlyArray<unknown>,
|
|
348
|
+
evidence: string,
|
|
349
|
+
) => `Revise the settings for the oxlint rule ${id}; it should enforce: ${idea}
|
|
350
|
+
|
|
351
|
+
A trial ran it on real files with the settings ${JSON.stringify(options)}. What it found, and what the user made of it:
|
|
352
|
+
|
|
353
|
+
${evidence}
|
|
354
|
+
|
|
355
|
+
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.`;
|
|
356
|
+
|
|
111
357
|
/** An agent's reply as the file it holds, without the fence it may wrap it in. */
|
|
112
358
|
export const unfenced = (text: string) =>
|
|
113
359
|
/^\s*```[a-z]*\n([\s\S]*?)\n```\s*$/.exec(text)?.[1] ?? text.trim();
|
|
@@ -122,12 +368,12 @@ export const asking = (agent: Agent, what: string, cwd: string, prompt: string,
|
|
|
122
368
|
}),
|
|
123
369
|
);
|
|
124
370
|
|
|
125
|
-
export const askingFor = <A>(
|
|
371
|
+
export const askingFor = <A, I>(
|
|
126
372
|
agent: Agent,
|
|
127
373
|
what: string,
|
|
128
374
|
cwd: string,
|
|
129
375
|
prompt: string,
|
|
130
|
-
Answer: Schema.Codec<A,
|
|
376
|
+
Answer: Schema.Codec<A, I>,
|
|
131
377
|
) =>
|
|
132
378
|
asking(
|
|
133
379
|
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
|
@@ -8,33 +8,74 @@ import { Status, clearingStatus } from "#services/Status.ts";
|
|
|
8
8
|
import { Console, Duration, Effect, Fiber } from "effect";
|
|
9
9
|
import { Prompt } from "effect/cli";
|
|
10
10
|
|
|
11
|
+
const rgbColor = (r: number, g: number, b: number) => `\x1B[38;2;${r};${g};${b}m`;
|
|
12
|
+
const colorCode = (n: number) => `\x1B[38;5;${n}m`;
|
|
13
|
+
const COLORS = {
|
|
14
|
+
PURPLE: colorCode(99),
|
|
15
|
+
KIWI: colorCode(156),
|
|
16
|
+
PINK: colorCode(212),
|
|
17
|
+
FG: rgbColor(220, 220, 240),
|
|
18
|
+
MUTED: rgbColor(180, 180, 190),
|
|
19
|
+
RED: colorCode(197),
|
|
20
|
+
ACCENT: rgbColor(242, 205, 205),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** One column of left padding, so the whole wizard sits clear of the terminal's edge. */
|
|
24
|
+
const PAD = " ";
|
|
25
|
+
|
|
11
26
|
/**
|
|
12
|
-
* The prompts' theme. Every
|
|
13
|
-
*
|
|
14
|
-
*
|
|
27
|
+
* The prompts' theme. Every symbol carries `PAD`, so each prompt's rows start
|
|
28
|
+
* where the rail's do; `pointerSmall` also pads a validation error and adds
|
|
29
|
+
* one space before the `›` that ends an active row.
|
|
15
30
|
*/
|
|
16
31
|
export const look: Partial<Prompt.Theme> = {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
// primaryColor: `\x1B[0;37m`,
|
|
33
|
+
// mutedColor: COLORS.MUTED,
|
|
34
|
+
successColor: COLORS.KIWI,
|
|
35
|
+
submittedColor: COLORS.PURPLE,
|
|
36
|
+
errorColor: COLORS.RED,
|
|
37
|
+
// toggleSeparator: " / ",
|
|
38
|
+
// passwordMask: `${COLORS.MUTED}*${ESC}`,
|
|
39
|
+
// prefix: `│\n│${COLORS.PINK} ◇${ESC}`,
|
|
40
|
+
// prefix: `\n ◇`,
|
|
41
|
+
// tick: `\n ✓`,
|
|
42
|
+
// tick: `│\n│${COLORS.KIWI} ✓${ESC}`,
|
|
43
|
+
// pointer: `${COLORS.PURPLE}‣${ESC}`,
|
|
20
44
|
arrowUp: "↑ ",
|
|
21
45
|
arrowDown: "↓ ",
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
46
|
+
prefix: `${PAD}?`,
|
|
47
|
+
pointer: `${PAD}❯`,
|
|
48
|
+
pointerSmall: `${PAD}›`,
|
|
49
|
+
ellipsis: "…",
|
|
50
|
+
tick: `${PAD}✔`,
|
|
51
|
+
descriptionSeparator: "\n\n - ",
|
|
52
|
+
checkboxOn: "",
|
|
53
|
+
checkboxOff: "",
|
|
54
|
+
colors: true,
|
|
27
55
|
};
|
|
28
56
|
|
|
29
57
|
const columns = () => process.stderr.columns ?? 80;
|
|
30
58
|
|
|
31
|
-
/** `text` wrapped to the terminal, each further line indented under
|
|
59
|
+
/** `text` wrapped to the terminal, each further line indented under the separator as the first is. */
|
|
32
60
|
export const indented = (text: string) =>
|
|
33
|
-
Bun.wrapAnsi(text, Math.max(columns() -
|
|
61
|
+
Bun.wrapAnsi(text, Math.max(columns() - 5, 20)).replaceAll("\n", "\n ");
|
|
62
|
+
|
|
63
|
+
/** Renders Markdown for preview with Bun's native Markdown-to-ANSI renderer. */
|
|
64
|
+
export const renderMarkdown = (text: string) =>
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const theme = yield* Prompt.Theme;
|
|
67
|
+
return Bun.markdown
|
|
68
|
+
.ansi(text, {
|
|
69
|
+
colors: theme.colors,
|
|
70
|
+
columns: Math.max(columns() - 4, 20),
|
|
71
|
+
})
|
|
72
|
+
.trimEnd();
|
|
73
|
+
});
|
|
34
74
|
|
|
35
75
|
type Color = (theme: Prompt.Theme) => string;
|
|
36
76
|
const muted: Color = (theme) => theme.mutedColor;
|
|
37
77
|
const primary: Color = (theme) => theme.primaryColor;
|
|
78
|
+
const accent: Color = (_theme) => COLORS.PINK;
|
|
38
79
|
|
|
39
80
|
const paint = (theme: Prompt.Theme, text: string, color?: Color) =>
|
|
40
81
|
theme.colors && color !== undefined ? `${color(theme)}${text}\x1b[0m` : text;
|
|
@@ -46,14 +87,16 @@ const paint = (theme: Prompt.Theme, text: string, color?: Color) =>
|
|
|
46
87
|
const rail = (symbol: string, symbolColor: Color, text: string, textColor?: Color, bar = "│") =>
|
|
47
88
|
Effect.gen(function* () {
|
|
48
89
|
const theme = yield* Prompt.Theme;
|
|
49
|
-
const [first = "", ...rest] = Bun.wrapAnsi(text, Math.max(columns() -
|
|
90
|
+
const [first = "", ...rest] = Bun.wrapAnsi(text, Math.max(columns() - 4, 20), {
|
|
50
91
|
hard: true,
|
|
51
92
|
trim: false,
|
|
52
93
|
}).split("\n");
|
|
53
94
|
yield* Console.error(
|
|
54
95
|
[
|
|
55
|
-
`${paint(theme, symbol, symbolColor)} ${paint(theme, first, textColor)}`,
|
|
56
|
-
...rest.map(
|
|
96
|
+
`${PAD}${paint(theme, symbol, symbolColor)} ${paint(theme, first, textColor)}`,
|
|
97
|
+
...rest.map(
|
|
98
|
+
(line) => `${PAD}${paint(theme, bar, muted)} ${paint(theme, line, textColor)}`,
|
|
99
|
+
),
|
|
57
100
|
].join("\n"),
|
|
58
101
|
);
|
|
59
102
|
});
|
|
@@ -91,7 +134,7 @@ export const excerpt = (
|
|
|
91
134
|
Effect.gen(function* () {
|
|
92
135
|
const theme = yield* Prompt.Theme;
|
|
93
136
|
const width = String(range.to).length;
|
|
94
|
-
const room = Math.max(columns() - width -
|
|
137
|
+
const room = Math.max(columns() - width - 9, 20);
|
|
95
138
|
const cut = lines.slice(range.from - 1, range.to).map((text) => {
|
|
96
139
|
const flat = text.replaceAll("\t", " ");
|
|
97
140
|
return flat.length > room ? `${flat.slice(0, room - 1)}…` : flat;
|
|
@@ -105,7 +148,7 @@ export const excerpt = (
|
|
|
105
148
|
const body = inside
|
|
106
149
|
? `${paint(theme, number, muted)} ${colored[index] ?? text}`
|
|
107
150
|
: paint(theme, `${number} ${text}`, muted);
|
|
108
|
-
return `${paint(theme, "│", muted)} ${marker} ${body}`;
|
|
151
|
+
return `${PAD}${paint(theme, "│", muted)} ${marker} ${body}`;
|
|
109
152
|
});
|
|
110
153
|
yield* Console.error(rows.join("\n"));
|
|
111
154
|
});
|
|
@@ -127,7 +170,7 @@ export const spinning = <A, E, R>(text: string, effect: Effect.Effect<A, E, R>)
|
|
|
127
170
|
const spinner = yield* Effect.forkChild(
|
|
128
171
|
Effect.suspend(() =>
|
|
129
172
|
status.show(
|
|
130
|
-
`${paint(theme, frames[frame++ % frames.length] ?? "",
|
|
173
|
+
`${PAD}${paint(theme, frames[frame++ % frames.length] ?? "", accent)} ${paint(theme, `${text}…`, muted)}`,
|
|
131
174
|
),
|
|
132
175
|
).pipe(Effect.andThen(Effect.sleep("110 millis")), Effect.forever),
|
|
133
176
|
);
|