@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.
- package/README.md +62 -1575
- package/package.json +18 -15
- package/presets/README.md +140 -0
- 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 -29
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +60 -26
- package/src/cli.ts +334 -36
- package/src/config.ts +173 -142
- package/src/create/agents.ts +149 -0
- package/src/create/ask.ts +363 -0
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +191 -0
- 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 +466 -0
- package/src/index.ts +2 -0
- package/src/init.ts +171 -41
- package/src/install.ts +296 -94
- package/src/instructions.ts +98 -0
- package/src/markdown.ts +15 -11
- 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/rules.ts +42 -45
- package/src/services/AdhereConfig.ts +67 -21
- package/src/services/AuditCache.ts +176 -169
- package/src/services/Credentials.ts +14 -8
- package/src/services/Jev.reads.test.ts +22 -0
- package/src/services/Jev.ts +35 -13
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +4 -4
- package/src/workflows/audit.ts +85 -14
- package/src/workflows/format.ts +98 -1
- package/src/workspaces.test.ts +37 -1
- package/src/workspaces.ts +121 -12
package/src/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type PresetName, presetNames } from "#presets.ts";
|
|
2
2
|
import { type Provider, SAVED_KEYS } from "#providers/provider.ts";
|
|
3
3
|
import type { RuleSet } from "#rules.ts";
|
|
4
|
-
import { Effect, Predicate, Schema
|
|
4
|
+
import { Effect, Predicate, Schema } from "effect";
|
|
5
5
|
|
|
6
6
|
export type RuleId = string;
|
|
7
7
|
|
|
@@ -32,7 +32,10 @@ const ProviderSchema = Schema.declare(
|
|
|
32
32
|
Predicate.hasProperty(value, "ask") &&
|
|
33
33
|
Predicate.isFunction(value.ask) &&
|
|
34
34
|
(!Predicate.hasProperty(value, "savedKey") || SAVED_KEYS.some((key) => key === value.savedKey)),
|
|
35
|
-
{
|
|
35
|
+
{
|
|
36
|
+
expected: "a provider: a model, and a function to ask it",
|
|
37
|
+
toCode: () => ({ runtime: "Provider", Type: "Provider" }),
|
|
38
|
+
},
|
|
36
39
|
);
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -54,14 +57,19 @@ export type AppendState = (
|
|
|
54
57
|
|
|
55
58
|
/**
|
|
56
59
|
* What the config can have Jev read beside each file's code, shared by all
|
|
57
|
-
* its rules.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
+
* its rules. A rule may also declare the reads it needs, which tells the run
|
|
61
|
+
* what would help without enabling any of it. Each name is the key the state
|
|
62
|
+
* carries it under, which a rule's description can name in backticks. `workspacePackages` maps only workspace
|
|
63
|
+
* packages imported by this file to their directories. `dependents` lists what
|
|
64
|
+
* depends on this file: workspace packages whose package.json names its package,
|
|
65
|
+
* and files that import it by a relative path. `symbols` maps names
|
|
60
66
|
* to native compiler types, with documentation and JSDoc tags shared across variants.
|
|
61
67
|
* `references` maps used names to complete AST declaration scopes by
|
|
62
68
|
* repo-relative path, rather than fixed line windows or full related files.
|
|
63
69
|
*/
|
|
64
|
-
export const Reads = Schema.Array(
|
|
70
|
+
export const Reads = Schema.Array(
|
|
71
|
+
Schema.Literals(["workspacePackages", "dependents", "symbols", "references"]),
|
|
72
|
+
);
|
|
65
73
|
|
|
66
74
|
/**
|
|
67
75
|
* A rule's examples, named in RFC 2119's words, which Jev reads as written. A
|
|
@@ -69,91 +77,88 @@ export const Reads = Schema.Array(Schema.Literals(["workspacePackages", "symbols
|
|
|
69
77
|
* be. A guideline, which asks for less, shows code that `should` be written
|
|
70
78
|
* and code that `shouldNot` be.
|
|
71
79
|
*/
|
|
72
|
-
const
|
|
73
|
-
description: Schema.String
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
80
|
+
export const Rule = Schema.Struct({
|
|
81
|
+
description: Schema.String.annotate({
|
|
82
|
+
description: "What the rule says, in one sentence. The report shows it with each finding.",
|
|
83
|
+
}),
|
|
84
|
+
details: Schema.optionalKey(Schema.String).annotate({
|
|
85
|
+
description:
|
|
86
|
+
"What the description leaves out, such as why the rule holds or where it does not: in a `RULE.md`, the prose around its code. Jev reads it after the description, as part of the rule; the report shows the description alone.",
|
|
87
|
+
}),
|
|
88
|
+
must: Schema.optionalKey(Schema.String).annotate({
|
|
89
|
+
description:
|
|
90
|
+
"Code the rule requires. A rule has `must` and `never`, or, as a guideline, `should` and `shouldNot`.",
|
|
91
|
+
}),
|
|
92
|
+
never: Schema.optionalKey(Schema.String).annotate({
|
|
93
|
+
description: "Code the rule forbids.",
|
|
94
|
+
}),
|
|
95
|
+
should: Schema.optionalKey(Schema.String).annotate({
|
|
96
|
+
description: "A guideline's code to write.",
|
|
97
|
+
}),
|
|
98
|
+
shouldNot: Schema.optionalKey(Schema.String).annotate({
|
|
99
|
+
description: "A guideline's code not to write.",
|
|
100
|
+
}),
|
|
101
|
+
threshold: Schema.optionalKey(Schema.Finite).annotate({
|
|
102
|
+
description: "The rule's own confidence threshold, 0 to 1, in place of the config's.",
|
|
103
|
+
}),
|
|
104
|
+
tests: Schema.optionalKey(Schema.Literals(["only", "include"])).annotate({
|
|
105
|
+
description:
|
|
106
|
+
"Test files, which a rule skips by default: `only` for a rule about tests, which judges nothing else, and `include` for one that holds in tests too.",
|
|
107
|
+
}),
|
|
108
|
+
level: Schema.optionalKey(Schema.Literals(["error", "warning"])).annotate({
|
|
109
|
+
description:
|
|
110
|
+
"How a finding under the rule is reported: an `error`, the default, which fails the run, or a `warning`, for a nit or a rule that errs toward false findings, which does not.",
|
|
111
|
+
}),
|
|
112
|
+
appliesTo: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
|
|
113
|
+
description:
|
|
114
|
+
"Where the rule applies, each a description of code that breaks it: Jev is asked of each whether any code that breaks the rule is as it describes, and a finding stands only when it says yes to every one.",
|
|
115
|
+
}),
|
|
116
|
+
excludeIf: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
|
|
117
|
+
description:
|
|
118
|
+
"Where the rule does not apply: Jev is asked of each whether all the code that breaks the rule is as it describes, and a yes to any drops the finding.",
|
|
119
|
+
}),
|
|
120
|
+
reads: Schema.optionalKey(Reads).annotate({
|
|
121
|
+
description:
|
|
122
|
+
"Context the rule needs, such as `dependents` or `symbols`. This declares what would help; the config's `reads` and `typeAware` still decide what this run enables. A project's rule that declares a read they do not enable warns that it may be less accurate until enabled; a bundled preset rule that declares `symbols` or `references` is left out unless `typeAware` is on.",
|
|
123
|
+
}),
|
|
113
124
|
appendState: Schema.optionalKey(
|
|
114
125
|
Schema.declare((value: unknown): value is AppendState => typeof value === "function", {
|
|
115
126
|
expected: "a function",
|
|
127
|
+
toCode: () => ({ runtime: "AppendState", Type: "AppendState" }),
|
|
116
128
|
}),
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// Guard the removed key instead of silently dropping it as an excess property.
|
|
126
|
-
reads: Schema.optionalKey(
|
|
127
|
-
Schema.declare((_value: unknown): _value is never => false, {
|
|
128
|
-
expected: "no rule-level reads; move reads to the top level of defineConfig({...})",
|
|
129
|
+
).annotate({
|
|
130
|
+
description:
|
|
131
|
+
"A hook on the state Jev reads for the rule, in a rule written in TypeScript (see `AppendState`). The rule's questions go in requests of their own, so only they read what it adds.",
|
|
132
|
+
}),
|
|
133
|
+
options: Schema.optionalKey(
|
|
134
|
+
Schema.declare((value: unknown): value is Schema.Decoder<unknown> => Schema.isSchema(value), {
|
|
135
|
+
expected: "an Effect Schema",
|
|
136
|
+
toCode: () => ({ runtime: "Schema", Type: "Schema.Decoder<unknown>" }),
|
|
129
137
|
}),
|
|
130
|
-
)
|
|
138
|
+
).annotate({
|
|
139
|
+
description:
|
|
140
|
+
"The settings a rule written in TypeScript takes, as an Effect Schema, set under the rule's id in a config's `overrides`, as `options`. A key the schema does not make optional must be set. Jev reads the decoded settings as `options`, in requests of the rule's own.",
|
|
141
|
+
}),
|
|
131
142
|
})
|
|
132
|
-
.
|
|
133
|
-
Schema.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
+
.check(
|
|
144
|
+
Schema.makeFilter(
|
|
145
|
+
(rule) => {
|
|
146
|
+
const requirement = rule.must !== undefined || rule.never !== undefined;
|
|
147
|
+
const guideline = rule.should !== undefined || rule.shouldNot !== undefined;
|
|
148
|
+
return requirement && guideline
|
|
149
|
+
? "a rule is a requirement (must, never) or a guideline (should, shouldNot), not both"
|
|
150
|
+
: requirement || guideline
|
|
151
|
+
? undefined
|
|
152
|
+
: "a rule needs an example: must, never, should, or shouldNot";
|
|
153
|
+
},
|
|
154
|
+
{ toCode: () => ({ runtime: "Schema.makeFilter(isRequirementOrGuideline)" }) },
|
|
143
155
|
),
|
|
144
156
|
)
|
|
145
|
-
.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
? "a rule is a requirement (must, never) or a guideline (should, shouldNot), not both"
|
|
151
|
-
: requirement || guideline
|
|
152
|
-
? undefined
|
|
153
|
-
: "a rule needs an example: must, never, should, or shouldNot";
|
|
154
|
-
}),
|
|
155
|
-
);
|
|
156
|
-
export interface Rule extends Schema.Schema.Type<typeof Rule> {}
|
|
157
|
+
.annotate({ identifier: "Rule" });
|
|
158
|
+
export interface Rule extends Schema.Schema.Type<typeof Rule> {
|
|
159
|
+
/** The settings `options` decoded from the config's override, set as the config is loaded. */
|
|
160
|
+
readonly optionValues?: unknown;
|
|
161
|
+
}
|
|
157
162
|
|
|
158
163
|
/** How a finding is reported: an error fails the run, a warning does not. */
|
|
159
164
|
export type Level = NonNullable<Rule["level"]>;
|
|
@@ -186,78 +191,84 @@ export type Rules = Readonly<Record<RuleId, Rule>>;
|
|
|
186
191
|
/** Rules inline, or a directory of Markdown rule files (see `markdown.ts`). */
|
|
187
192
|
export type RuleSource = Rules | string | URL;
|
|
188
193
|
|
|
189
|
-
/** A rule's setting in a config's `overrides`: `off`, a level, or a level and
|
|
194
|
+
/** A rule's setting in a config's `overrides`: `off`, a level, or a level, a threshold, and its options. */
|
|
190
195
|
const Override = Schema.Union([
|
|
191
196
|
Schema.Literals(["off", "error", "warning"]),
|
|
192
197
|
Schema.Struct({
|
|
193
198
|
level: Schema.optionalKey(Schema.Literals(["off", "error", "warning"])),
|
|
194
199
|
threshold: Schema.optionalKey(Schema.Finite),
|
|
200
|
+
options: Schema.optionalKey(Schema.Unknown),
|
|
195
201
|
}),
|
|
196
202
|
]);
|
|
197
203
|
export type Override = typeof Override.Type;
|
|
198
204
|
|
|
205
|
+
/** A config file's keys. Each `description` is what the docs' Config file reference shows. */
|
|
199
206
|
export const AdhereConfig = Schema.Struct({
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
maxSymbolLength: Schema.optionalKey(Schema.Natural)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
207
|
+
model: Schema.optionalKey(Schema.String).annotate({
|
|
208
|
+
description: `The model id sent to TypeSafe AI. Defaults to "jev-latest".`,
|
|
209
|
+
}),
|
|
210
|
+
provider: Schema.optionalKey(ProviderSchema).annotate({
|
|
211
|
+
description:
|
|
212
|
+
"Where the questions go in place of TypeSafe's Jev, with its own model (see `Provider`).",
|
|
213
|
+
}),
|
|
214
|
+
threshold: Schema.optionalKey(Schema.Finite).annotate({
|
|
215
|
+
description: "The minimum confidence threshold for a positive match. Defaults to 0.8.",
|
|
216
|
+
}),
|
|
217
|
+
sufficiencyThreshold: Schema.optionalKey(Schema.Finite).annotate({
|
|
218
|
+
description:
|
|
219
|
+
"Below this, Jev's probability that a file shows enough to decide a finding's rule adds a warning to the finding: check what the code relies on outside the file. Defaults to 0.6.",
|
|
220
|
+
}),
|
|
221
|
+
presets: Schema.Array(Schema.Literals(presetNames))
|
|
222
|
+
.pipe(Schema.withDecodingDefaultKey(Effect.succeed([])))
|
|
223
|
+
.annotate({
|
|
224
|
+
description:
|
|
225
|
+
"The built-in rule sets to use, such as `effect` or `alchemy`. Append `/<topic>` for one topic, as in `effect/basics`. Defaults to none.",
|
|
226
|
+
}),
|
|
227
|
+
reads: Schema.optionalKey(Reads).annotate({
|
|
228
|
+
description:
|
|
229
|
+
"Workspace context shared by every rule: `workspacePackages` (the default) and `dependents`; `[]` disables it. Native read names do not enable type awareness.",
|
|
230
|
+
}),
|
|
231
|
+
typeAware: Schema.optionalKey(Schema.Boolean).annotate({
|
|
232
|
+
description:
|
|
233
|
+
"Include both compiler-resolved symbols and declaration excerpts. Defaults to false. Bundled preset rules that declare either run only with it on; a project's own rule still runs and warns.",
|
|
234
|
+
}),
|
|
235
|
+
maxSymbolLength: Schema.optionalKey(Schema.Natural).annotate({
|
|
236
|
+
description: "Maximum characters of each symbol's complete resolved content. Defaults to 256.",
|
|
237
|
+
}),
|
|
238
|
+
maxSymbolDocumentationLength: Schema.optionalKey(Schema.Natural).annotate({
|
|
239
|
+
description:
|
|
240
|
+
"Maximum characters of documentation and JSDoc tags per symbol. Defaults to 256; 0 omits them.",
|
|
241
|
+
}),
|
|
242
|
+
rules: Schema.optionalKey(
|
|
243
|
+
Schema.Union([Schema.Record(Schema.String, Rule), Schema.String]),
|
|
244
|
+
).annotate({
|
|
245
|
+
description:
|
|
246
|
+
"A record of rules (see Rule keys), or a directory path relative to the config file, which holds its rules as `.adhere/rules/` does. Unset: the rules in `.adhere/rules/`, and in any nested `.adhere/rules/`.",
|
|
247
|
+
}),
|
|
248
|
+
instructions: Schema.optionalKey(Schema.NonEmptyString).annotate({
|
|
249
|
+
description:
|
|
250
|
+
"Instructions directory relative to the config file. Unset: `.adhere/instructions`, then `.agents/instructions`.",
|
|
251
|
+
}),
|
|
252
|
+
exclude: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
|
|
253
|
+
description:
|
|
254
|
+
"Globs, relative to the working directory, of files no rule judges: what `--filter '!<glob>'` leaves out of one run, left out of every run.",
|
|
255
|
+
}),
|
|
256
|
+
overrides: Schema.optionalKey(Schema.Record(Schema.String, Override)).annotate({
|
|
257
|
+
description:
|
|
258
|
+
"Settings for rules by the id a report names them with, a preset's with its preset first, as in `alchemy/providers/idempotent-delete`: `off`, a level, or `{ level, threshold, options }`. A preset's rule changes without its text being copied into `rules`.",
|
|
259
|
+
}),
|
|
260
|
+
includeComments: Schema.optionalKey(Schema.Boolean).annotate({
|
|
261
|
+
description:
|
|
262
|
+
"Whether Jev reads every comment, for rules about comments. By default comments are taken out, since a comment changes nothing a file does, but for the notes that say `@adhere`.",
|
|
263
|
+
}),
|
|
255
264
|
});
|
|
256
265
|
|
|
257
266
|
/** Where a repo keeps what adhere reads and writes: its config, its rules, and the cache. */
|
|
258
267
|
export const ADHERE_DIRECTORY = ".adhere";
|
|
259
268
|
/** Where a repo keeps its rules by default, one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`. */
|
|
260
269
|
export const RULES_DIRECTORY = `${ADHERE_DIRECTORY}/rules`;
|
|
270
|
+
/** Where a repo keeps the Markdown instruction files postinstall renders into AGENTS.md. */
|
|
271
|
+
export const INSTRUCTIONS_DIRECTORY = `${ADHERE_DIRECTORY}/instructions`;
|
|
261
272
|
export const CACHE_DIRECTORY = `${ADHERE_DIRECTORY}/cache`;
|
|
262
273
|
/** Where a config may live, relative to the working directory. Exactly one may exist. */
|
|
263
274
|
export const CONFIG_FILES = [
|
|
@@ -366,9 +377,11 @@ export interface ResolvedConfig {
|
|
|
366
377
|
/**
|
|
367
378
|
* The workspace's packages, each name with its directory from the working
|
|
368
379
|
* directory, read from its manifests on every run when the config's reads
|
|
369
|
-
* names
|
|
380
|
+
* names `workspacePackages` or `dependents`.
|
|
370
381
|
*/
|
|
371
382
|
readonly workspacePackages?: Readonly<Record<string, string>>;
|
|
383
|
+
/** Per workspace package, the workspace packages that depend on it, when reads names `dependents`. */
|
|
384
|
+
readonly workspaceDependents?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
372
385
|
}
|
|
373
386
|
|
|
374
387
|
/** Command-line values that apply on top of the config file. */
|
|
@@ -400,6 +413,22 @@ export const presetsOf = (
|
|
|
400
413
|
});
|
|
401
414
|
};
|
|
402
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Whether the run enables the compiler's native reads: the command line
|
|
418
|
+
* replaces the config's opt-in, which defaults to off.
|
|
419
|
+
*/
|
|
420
|
+
export const typeAwareOf = (
|
|
421
|
+
config: Pick<typeof AdhereConfig.Type, "typeAware">,
|
|
422
|
+
flags: Flags,
|
|
423
|
+
): boolean => flags.typeAware ?? config.typeAware ?? false;
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Whether a rule declares a read only `typeAware` enables, so a run without
|
|
427
|
+
* it cannot judge the rule as it was written.
|
|
428
|
+
*/
|
|
429
|
+
export const declaresNativeReads = (rule: Pick<Rule, "reads">): boolean =>
|
|
430
|
+
(rule.reads ?? []).some((read) => read === "symbols" || read === "references");
|
|
431
|
+
|
|
403
432
|
/**
|
|
404
433
|
* Precedence, highest first: command line, config file, presets in order
|
|
405
434
|
* (a later preset wins), then the defaults. A config rule replaces a preset
|
|
@@ -416,7 +445,7 @@ export const resolveConfig = (
|
|
|
416
445
|
});
|
|
417
446
|
const last = <K extends "model" | "threshold">(key: K) =>
|
|
418
447
|
applied.map((preset) => preset[key]).findLast((value) => value !== undefined);
|
|
419
|
-
const typeAware =
|
|
448
|
+
const typeAware = typeAwareOf(config, flags);
|
|
420
449
|
return {
|
|
421
450
|
model: config.provider?.model ?? config.model ?? last("model") ?? DEFAULT_MODEL,
|
|
422
451
|
...(config.provider === undefined ? {} : { provider: config.provider }),
|
|
@@ -429,7 +458,9 @@ export const resolveConfig = (
|
|
|
429
458
|
...(config.includeComments === undefined ? {} : { includeComments: config.includeComments }),
|
|
430
459
|
typeAware,
|
|
431
460
|
reads: [
|
|
432
|
-
...(config.reads ?? DEFAULT_READS).filter(
|
|
461
|
+
...(config.reads ?? DEFAULT_READS).filter(
|
|
462
|
+
(read) => read === "workspacePackages" || read === "dependents",
|
|
463
|
+
),
|
|
433
464
|
...(typeAware ? (["symbols", "references"] as const) : []),
|
|
434
465
|
],
|
|
435
466
|
maxSymbolLength: config.maxSymbolLength ?? DEFAULT_MAX_SYMBOL_LENGTH,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coding agents installed beside adhere, asked one question at a time
|
|
3
|
+
* without a session: `claude -p`, `codex exec`, and `omp -p`. The prompt goes
|
|
4
|
+
* on stdin, the agent may read the repo it runs in but not change it, and
|
|
5
|
+
* what comes back is the text of its last message. Given a JSON schema, the
|
|
6
|
+
* agent is held to it where its CLI can, and the text is that JSON.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { Option, Schema } from "effect";
|
|
12
|
+
|
|
13
|
+
export const AGENTS = ["claude", "codex", "omp"] as const;
|
|
14
|
+
export type Agent = (typeof AGENTS)[number];
|
|
15
|
+
|
|
16
|
+
/** The agents on PATH, in the order adhere prefers them. */
|
|
17
|
+
export const installedAgents = (): ReadonlyArray<Agent> =>
|
|
18
|
+
AGENTS.filter((agent) => Bun.which(agent) !== null);
|
|
19
|
+
|
|
20
|
+
/** `claude -p --output-format json` prints its events, the last its result. */
|
|
21
|
+
const ClaudeResult = Schema.fromJsonString(
|
|
22
|
+
Schema.Array(
|
|
23
|
+
Schema.Struct({
|
|
24
|
+
type: Schema.String,
|
|
25
|
+
is_error: Schema.optionalKey(Schema.Boolean),
|
|
26
|
+
result: Schema.optionalKey(Schema.String),
|
|
27
|
+
structured_output: Schema.optionalKey(Schema.Unknown),
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** The line of `omp --mode json` that ends an assistant's message; every other line is skipped. */
|
|
33
|
+
const OmpAnswer = Schema.fromJsonString(
|
|
34
|
+
Schema.Struct({
|
|
35
|
+
type: Schema.Literal("message_end"),
|
|
36
|
+
message: Schema.Struct({
|
|
37
|
+
role: Schema.Literal("assistant"),
|
|
38
|
+
content: Schema.Array(
|
|
39
|
+
Schema.Struct({ type: Schema.String, text: Schema.optionalKey(Schema.String) }),
|
|
40
|
+
),
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Runs `command` in `cwd` with `input` on stdin, and returns its stdout once it exits 0. */
|
|
46
|
+
export const run = async (
|
|
47
|
+
command: string,
|
|
48
|
+
args: ReadonlyArray<string>,
|
|
49
|
+
cwd: string,
|
|
50
|
+
input: string,
|
|
51
|
+
) => {
|
|
52
|
+
const child = Bun.spawn([command, ...args], {
|
|
53
|
+
cwd,
|
|
54
|
+
stdin: new Blob([input]),
|
|
55
|
+
stdout: "pipe",
|
|
56
|
+
stderr: "pipe",
|
|
57
|
+
});
|
|
58
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
59
|
+
new Response(child.stdout).text(),
|
|
60
|
+
new Response(child.stderr).text(),
|
|
61
|
+
child.exited,
|
|
62
|
+
]);
|
|
63
|
+
if (code !== 0) throw new Error(`${command} exited ${code}: ${stderr.trim().slice(-500)}`);
|
|
64
|
+
return stdout;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The text of `agent`'s answer to `prompt`, asked in `cwd`; with `schema`, its JSON. */
|
|
68
|
+
export const askAgent = async (
|
|
69
|
+
agent: Agent,
|
|
70
|
+
prompt: string,
|
|
71
|
+
cwd: string,
|
|
72
|
+
schema?: object,
|
|
73
|
+
): Promise<string> => {
|
|
74
|
+
switch (agent) {
|
|
75
|
+
case "claude": {
|
|
76
|
+
const events = Schema.decodeUnknownSync(ClaudeResult)(
|
|
77
|
+
await run(
|
|
78
|
+
"claude",
|
|
79
|
+
[
|
|
80
|
+
"-p",
|
|
81
|
+
"--output-format=json",
|
|
82
|
+
"--tools=Read,Grep,Glob",
|
|
83
|
+
"--no-session-persistence",
|
|
84
|
+
...(schema === undefined ? [] : [`--json-schema=${JSON.stringify(schema)}`]),
|
|
85
|
+
],
|
|
86
|
+
cwd,
|
|
87
|
+
prompt,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
const result = events.findLast((event) => event.type === "result");
|
|
91
|
+
if (result === undefined || result.is_error === true) {
|
|
92
|
+
throw new Error(`claude did not answer: ${result?.result ?? "no result"}`);
|
|
93
|
+
}
|
|
94
|
+
return schema === undefined
|
|
95
|
+
? (result.result ?? "")
|
|
96
|
+
: JSON.stringify(result.structured_output);
|
|
97
|
+
}
|
|
98
|
+
case "codex": {
|
|
99
|
+
const dir = await mkdtemp(join(tmpdir(), "adhere-codex-"));
|
|
100
|
+
try {
|
|
101
|
+
const out = join(dir, "answer");
|
|
102
|
+
const schemaFile = join(dir, "schema.json");
|
|
103
|
+
if (schema !== undefined) await writeFile(schemaFile, JSON.stringify(schema));
|
|
104
|
+
await run(
|
|
105
|
+
"codex",
|
|
106
|
+
[
|
|
107
|
+
"exec",
|
|
108
|
+
"--sandbox=read-only",
|
|
109
|
+
"--ephemeral",
|
|
110
|
+
"--skip-git-repo-check",
|
|
111
|
+
"--color=never",
|
|
112
|
+
`--output-last-message=${out}`,
|
|
113
|
+
...(schema === undefined ? [] : [`--output-schema=${schemaFile}`]),
|
|
114
|
+
"-",
|
|
115
|
+
],
|
|
116
|
+
cwd,
|
|
117
|
+
prompt,
|
|
118
|
+
);
|
|
119
|
+
return await readFile(out, "utf8");
|
|
120
|
+
} finally {
|
|
121
|
+
await rm(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
case "omp": {
|
|
125
|
+
// omp holds no answer to a schema, so the schema goes in the prompt and
|
|
126
|
+
// the JSON is cut out of whatever surrounds it.
|
|
127
|
+
const asked =
|
|
128
|
+
schema === undefined
|
|
129
|
+
? prompt
|
|
130
|
+
: `${prompt}\n\nReply with only JSON matching this schema:\n${JSON.stringify(schema)}`;
|
|
131
|
+
const lines = (
|
|
132
|
+
await run(
|
|
133
|
+
"omp",
|
|
134
|
+
["-p", "--mode=json", "--tools=read,grep,glob", "--no-session", "--no-title"],
|
|
135
|
+
cwd,
|
|
136
|
+
asked,
|
|
137
|
+
)
|
|
138
|
+
).split("\n");
|
|
139
|
+
const answer = lines
|
|
140
|
+
.flatMap((line) => Option.toArray(Schema.decodeUnknownOption(OmpAnswer)(line)))
|
|
141
|
+
.at(-1);
|
|
142
|
+
if (answer === undefined) throw new Error("omp did not answer");
|
|
143
|
+
const text = answer.message.content
|
|
144
|
+
.flatMap((part) => (part.type === "text" && part.text !== undefined ? [part.text] : []))
|
|
145
|
+
.join("");
|
|
146
|
+
return schema === undefined ? text : text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|