@drkmttr/adhere 0.18.1 → 0.21.2
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 +1099 -1088
- package/package.json +15 -15
- package/presets/README.md +132 -0
- package/skills/adhere/SKILL.md +4 -3
- package/skills/adhere-setup/SKILL.md +11 -10
- package/src/cli.ts +121 -14
- package/src/config.ts +147 -139
- package/src/create/agents.ts +149 -0
- package/src/create/ask.ts +147 -0
- package/src/create/look.ts +140 -0
- package/src/create/wizard.ts +432 -0
- package/src/index.ts +2 -0
- package/src/init.ts +2 -1
- package/src/install.ts +1 -1
- package/src/instructions.ts +76 -0
- package/src/markdown.ts +3 -9
- package/src/rules.ts +42 -45
- package/src/services/AdhereConfig.ts +31 -18
- 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/wording.ts +1 -1
- package/src/workflows/audit.ts +82 -14
- package/src/workflows/format.ts +97 -0
- package/src/workspaces.test.ts +37 -1
- package/src/workspaces.ts +121 -12
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `adhere rules create`: a wizard that writes one rule with the user, asking
|
|
3
|
+
* an installed coding agent three times along the way. It suggests a scope,
|
|
4
|
+
* names, a RULE.md or a RULE.ts, and the context Jev needs; finds code in the
|
|
5
|
+
* repo for the user to judge; and writes the rule, which adhere checks as
|
|
6
|
+
* `validate` does and sends back once when it falls short. A rule for the
|
|
7
|
+
* organization goes to its shared rules repo, cloned, on a branch pushed for a
|
|
8
|
+
* pull request. The first run, or `--setup`, saves which agent to ask and
|
|
9
|
+
* which repo is the organization's.
|
|
10
|
+
*/
|
|
11
|
+
import { CONFIG_FILES, DEFAULT_READS } from "#config.ts";
|
|
12
|
+
import { AGENTS, type Agent, installedAgents, run } from "#create/agents.ts";
|
|
13
|
+
import {
|
|
14
|
+
type Candidate,
|
|
15
|
+
Candidates,
|
|
16
|
+
type Reviewed,
|
|
17
|
+
RuleNotCreated,
|
|
18
|
+
Shape,
|
|
19
|
+
asking,
|
|
20
|
+
askingFor,
|
|
21
|
+
examplesPrompt,
|
|
22
|
+
shapePrompt,
|
|
23
|
+
unfenced,
|
|
24
|
+
writePrompt,
|
|
25
|
+
} from "#create/ask.ts";
|
|
26
|
+
import {
|
|
27
|
+
decided,
|
|
28
|
+
excerpt,
|
|
29
|
+
indented,
|
|
30
|
+
intro,
|
|
31
|
+
line,
|
|
32
|
+
look,
|
|
33
|
+
note,
|
|
34
|
+
outro,
|
|
35
|
+
said,
|
|
36
|
+
spinning,
|
|
37
|
+
warn,
|
|
38
|
+
} from "#create/look.ts";
|
|
39
|
+
import { cloned } from "#install.ts";
|
|
40
|
+
import { parseRuleMarkdown } from "#markdown.ts";
|
|
41
|
+
import { importRule } from "#rules.ts";
|
|
42
|
+
import { findConfigFile, loadConfigFile } from "#services/AdhereConfig.ts";
|
|
43
|
+
import { adhereConfigDirectory } from "#services/Credentials.ts";
|
|
44
|
+
import { sentenceOf, strayingOf } from "#wording.ts";
|
|
45
|
+
import { Config, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
46
|
+
import { Prompt } from "effect/cli";
|
|
47
|
+
|
|
48
|
+
const RULE_ID = /^[a-z0-9]+(-[a-z0-9]+)*(\/[a-z0-9]+(-[a-z0-9]+)*)+$/;
|
|
49
|
+
|
|
50
|
+
/** What setup saves: the agent to ask, and the organization's shared rules repo, when it has one. */
|
|
51
|
+
const Settings = Schema.Struct({
|
|
52
|
+
agent: Schema.Literals(AGENTS),
|
|
53
|
+
sharedRules: Schema.optionalKey(Schema.String),
|
|
54
|
+
});
|
|
55
|
+
type Settings = typeof Settings.Type;
|
|
56
|
+
|
|
57
|
+
/** `settings.json` beside `adhere login`'s keys. */
|
|
58
|
+
const settingsFile = Effect.gen(function* () {
|
|
59
|
+
const path = yield* Path.Path;
|
|
60
|
+
return yield* adhereConfigDirectory(path).pipe(
|
|
61
|
+
Config.map((directory) => path.join(directory, "settings.json")),
|
|
62
|
+
Effect.mapError(
|
|
63
|
+
() =>
|
|
64
|
+
new RuleNotCreated({
|
|
65
|
+
message: "No home directory to keep settings in: set HOME or XDG_CONFIG_HOME.",
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const setUp = (file: string, named: Option.Option<Agent>) =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const fs = yield* FileSystem.FileSystem;
|
|
74
|
+
const path = yield* Path.Path;
|
|
75
|
+
const installed = installedAgents();
|
|
76
|
+
const [only, ...others] = installed;
|
|
77
|
+
if (Option.isNone(named) && only === undefined) {
|
|
78
|
+
return yield* new RuleNotCreated({
|
|
79
|
+
message:
|
|
80
|
+
"No coding agent found on PATH: install claude, codex, or omp, or hand `adhere skill docs` to the agent you use.",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const agent = Option.isSome(named)
|
|
84
|
+
? named.value
|
|
85
|
+
: only !== undefined && others.length === 0
|
|
86
|
+
? only
|
|
87
|
+
: yield* Prompt.run(
|
|
88
|
+
Prompt.Select({
|
|
89
|
+
message: "Which agent should help write rules?",
|
|
90
|
+
choices: installed.map((agent) => ({ title: agent, value: agent })),
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const shared = yield* Prompt.run(
|
|
94
|
+
Prompt.String({
|
|
95
|
+
message: "Your organization's shared rules repo on GitHub, as org/repo (empty for none)",
|
|
96
|
+
validate: (value) =>
|
|
97
|
+
value === "" || /^[\w.-]+\/[\w.-]+$/.test(value)
|
|
98
|
+
? Effect.succeed(value)
|
|
99
|
+
: Effect.fail("org/repo, as on GitHub, or empty."),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
const settings: Settings = { agent, ...(shared === "" ? {} : { sharedRules: shared }) };
|
|
103
|
+
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
|
|
104
|
+
yield* fs.writeFileString(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
105
|
+
yield* note(`Saved to ${file}. Run adhere rules create --setup to change it.`);
|
|
106
|
+
return settings;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const failed = (cause: unknown) =>
|
|
110
|
+
new RuleNotCreated({ message: cause instanceof Error ? cause.message : String(cause) });
|
|
111
|
+
|
|
112
|
+
const git = (cwd: string, args: ReadonlyArray<string>) =>
|
|
113
|
+
Effect.tryPromise({ try: () => run("git", args, cwd, ""), catch: failed });
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Shows the user each excerpt the agent found, read from the file itself with
|
|
117
|
+
* three lines either side, and asks whether it breaks the rule. An excerpt
|
|
118
|
+
* that does not match the repo is left out.
|
|
119
|
+
*/
|
|
120
|
+
const review = (agent: Agent, root: string, candidates: ReadonlyArray<Candidate>) =>
|
|
121
|
+
Effect.gen(function* () {
|
|
122
|
+
const fs = yield* FileSystem.FileSystem;
|
|
123
|
+
const path = yield* Path.Path;
|
|
124
|
+
const reviewed: Array<Reviewed> = [];
|
|
125
|
+
let missing = 0;
|
|
126
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
127
|
+
const file = path.resolve(root, candidate.file);
|
|
128
|
+
const where = path.relative(root, file);
|
|
129
|
+
const text = where.startsWith("..")
|
|
130
|
+
? Option.none()
|
|
131
|
+
: yield* fs.readFileString(file).pipe(Effect.option);
|
|
132
|
+
const lines = Option.getOrElse(text, () => "").split("\n");
|
|
133
|
+
const start = candidate.startLine;
|
|
134
|
+
if (Option.isNone(text) || start < 1 || start > lines.length) {
|
|
135
|
+
missing++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const end = Math.min(Math.max(candidate.endLine, start), start + 14, lines.length);
|
|
139
|
+
const at = `${where}:${start === end ? start : `${start}-${end}`}`;
|
|
140
|
+
yield* line("");
|
|
141
|
+
yield* line(`${at} (${index + 1} of ${candidates.length})`);
|
|
142
|
+
yield* excerpt(lines, {
|
|
143
|
+
from: Math.max(1, start - 3),
|
|
144
|
+
to: Math.min(lines.length, end + 3),
|
|
145
|
+
start,
|
|
146
|
+
end,
|
|
147
|
+
});
|
|
148
|
+
yield* said(
|
|
149
|
+
`${agent}: ${candidate.violation ? "breaks it" : "follows it"}. ${candidate.reason}`,
|
|
150
|
+
);
|
|
151
|
+
const violation = yield* Prompt.run(
|
|
152
|
+
Prompt.Confirm({ message: "Is this a violation?", initial: candidate.violation }),
|
|
153
|
+
);
|
|
154
|
+
reviewed.push({
|
|
155
|
+
where: at,
|
|
156
|
+
code: lines.slice(start - 1, end).join("\n"),
|
|
157
|
+
violation,
|
|
158
|
+
agreed: violation === candidate.violation,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (missing > 0) {
|
|
162
|
+
yield* note(`Left out ${missing} excerpt${missing === 1 ? "" : "s"} not found in the repo.`);
|
|
163
|
+
}
|
|
164
|
+
return reviewed;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What adhere can say of a draft without running it: for a RULE.md, what
|
|
169
|
+
* `validate` would; for a RULE.ts, whether it parses and has a default export.
|
|
170
|
+
*/
|
|
171
|
+
const problemsOf = (text: string, file: string, format: Shape["format"]) =>
|
|
172
|
+
format === "markdown"
|
|
173
|
+
? parseRuleMarkdown(text, file).pipe(
|
|
174
|
+
Effect.map((rule) => strayingOf(rule).map(sentenceOf)),
|
|
175
|
+
Effect.catchTag("ConfigUnavailable", ({ message }) => Effect.succeed([message])),
|
|
176
|
+
)
|
|
177
|
+
: Effect.try({
|
|
178
|
+
try: () => new Bun.Transpiler({ loader: "ts" }).scan(text).exports,
|
|
179
|
+
catch: (cause) => (cause instanceof Error ? cause.message : String(cause)),
|
|
180
|
+
}).pipe(
|
|
181
|
+
Effect.map((exports) =>
|
|
182
|
+
exports.includes("default") ? [] : ["It has no default export: defineRule({...})."],
|
|
183
|
+
),
|
|
184
|
+
Effect.catch((message) => Effect.succeed([`It does not parse: ${message}`])),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What `validate` would say of a RULE.ts the user has approved: imported from
|
|
189
|
+
* a temporary directory, so its code runs.
|
|
190
|
+
*/
|
|
191
|
+
const loadedProblems = (text: string, shown: string) =>
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
const fs = yield* FileSystem.FileSystem;
|
|
194
|
+
const path = yield* Path.Path;
|
|
195
|
+
const file = path.join(
|
|
196
|
+
yield* fs.makeTempDirectoryScoped({ prefix: "adhere-rule-" }),
|
|
197
|
+
"RULE.ts",
|
|
198
|
+
);
|
|
199
|
+
yield* fs.writeFileString(file, text);
|
|
200
|
+
return yield* importRule(file).pipe(
|
|
201
|
+
Effect.map((rule) => strayingOf(rule).map(sentenceOf)),
|
|
202
|
+
Effect.catchTag("ConfigUnavailable", ({ message }) =>
|
|
203
|
+
Effect.succeed([message.replaceAll(file, shown)]),
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The config settings the rule needs that `config` lacks, as they would be
|
|
210
|
+
* written in it. Reads and type awareness are config-wide, not per rule.
|
|
211
|
+
*/
|
|
212
|
+
const settingsFor = (
|
|
213
|
+
shape: Shape,
|
|
214
|
+
config: { readonly typeAware?: boolean; readonly reads?: ReadonlyArray<string> },
|
|
215
|
+
) => {
|
|
216
|
+
const reads = config.reads ?? DEFAULT_READS;
|
|
217
|
+
const missing = shape.reads.filter((read) => !reads.includes(read));
|
|
218
|
+
return [
|
|
219
|
+
...(shape.typeAware && config.typeAware !== true ? ["typeAware: true"] : []),
|
|
220
|
+
...(missing.length === 0
|
|
221
|
+
? []
|
|
222
|
+
: [`reads: [${[...reads, ...missing].map((read) => `"${read}"`).join(", ")}]`]),
|
|
223
|
+
];
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const createRule = Effect.fn("createRule")(
|
|
227
|
+
function* (given: Option.Option<string>, named: Option.Option<Agent>, setup: boolean) {
|
|
228
|
+
yield* intro("adhere rules create");
|
|
229
|
+
const fs = yield* FileSystem.FileSystem;
|
|
230
|
+
const path = yield* Path.Path;
|
|
231
|
+
const root = path.resolve();
|
|
232
|
+
const saved = yield* settingsFile;
|
|
233
|
+
const settings =
|
|
234
|
+
setup || !(yield* fs.exists(saved))
|
|
235
|
+
? yield* setUp(saved, named)
|
|
236
|
+
: yield* fs.readFileString(saved).pipe(
|
|
237
|
+
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Settings))),
|
|
238
|
+
Effect.mapError(
|
|
239
|
+
(problem) =>
|
|
240
|
+
new RuleNotCreated({
|
|
241
|
+
message: `${saved}: ${problem.message}. Run adhere rules create --setup to write it again.`,
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
const agent = Option.getOrElse(named, () => settings.agent);
|
|
246
|
+
const shared = settings.sharedRules;
|
|
247
|
+
const idea = Option.isSome(given)
|
|
248
|
+
? given.value
|
|
249
|
+
: yield* Prompt.run(Prompt.String({ message: "What should the rule enforce?" }));
|
|
250
|
+
|
|
251
|
+
const shape = yield* askingFor(agent, "how to shape it", root, shapePrompt(idea), Shape);
|
|
252
|
+
if (shape.linter !== null) {
|
|
253
|
+
const anyway = yield* Prompt.run(
|
|
254
|
+
Prompt.Confirm({
|
|
255
|
+
message: `A linter could check this: ${shape.linter}. Write an adhere rule anyway?`,
|
|
256
|
+
initial: false,
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
if (!anyway) return yield* outro("Nothing written.");
|
|
260
|
+
}
|
|
261
|
+
const ruleFile = shape.format === "typescript" ? "RULE.ts" : "RULE.md";
|
|
262
|
+
yield* decided(`A ${ruleFile}`, shape.formatReason);
|
|
263
|
+
yield* decided(
|
|
264
|
+
`Jev reads ${["the code", ...shape.reads, ...(shape.typeAware ? ["its types"] : [])].join(", ")}`,
|
|
265
|
+
shape.contextReason,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const scopes = [
|
|
269
|
+
{ title: "This project", value: "project" as const, description: ".adhere/rules/" },
|
|
270
|
+
{ title: "The organization", value: "org" as const, description: shared ?? "" },
|
|
271
|
+
].map((choice) =>
|
|
272
|
+
choice.value === shape.scope
|
|
273
|
+
? { ...choice, description: indented(shape.scopeReason) }
|
|
274
|
+
: choice,
|
|
275
|
+
);
|
|
276
|
+
if (shared === undefined && shape.scope === "org") {
|
|
277
|
+
yield* warn(
|
|
278
|
+
`${agent} suggests the organization's shared rules, but none is set up, so the rule goes in this project.\nRun adhere rules create --setup to name the shared repo.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const scope =
|
|
282
|
+
shared === undefined
|
|
283
|
+
? "project"
|
|
284
|
+
: yield* Prompt.run(
|
|
285
|
+
Prompt.Select({
|
|
286
|
+
message: "Where does the rule belong?",
|
|
287
|
+
choices: shape.scope === "org" ? scopes.toReversed() : scopes,
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
const repo =
|
|
291
|
+
scope === "org" && shared !== undefined
|
|
292
|
+
? yield* spinning(`Cloning ${shared}`, cloned({ repo: shared, rule: "" }))
|
|
293
|
+
: root;
|
|
294
|
+
const rules = path.join(repo, ".adhere", "rules");
|
|
295
|
+
|
|
296
|
+
const free = (id: string) =>
|
|
297
|
+
fs.exists(path.join(rules, id)).pipe(
|
|
298
|
+
Effect.orElseSucceed(() => true),
|
|
299
|
+
Effect.map((taken) => !taken),
|
|
300
|
+
);
|
|
301
|
+
const suggested = yield* Effect.filter(
|
|
302
|
+
shape.names.filter((id) => RULE_ID.test(id)),
|
|
303
|
+
free,
|
|
304
|
+
);
|
|
305
|
+
const picked = yield* Prompt.run(
|
|
306
|
+
Prompt.Select({
|
|
307
|
+
message: "Name",
|
|
308
|
+
choices: [
|
|
309
|
+
...suggested.map((id) => ({ title: id, value: Option.some(id) })),
|
|
310
|
+
{ title: "Type my own", value: Option.none<string>() },
|
|
311
|
+
],
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
const id = Option.isSome(picked)
|
|
315
|
+
? picked.value
|
|
316
|
+
: yield* Prompt.run(
|
|
317
|
+
Prompt.String({
|
|
318
|
+
message: "Rule id, as topic/rule-name",
|
|
319
|
+
validate: (value) =>
|
|
320
|
+
!RULE_ID.test(value)
|
|
321
|
+
? Effect.fail("Lowercase kebab case, under a topic: topic/rule-name.")
|
|
322
|
+
: free(value).pipe(
|
|
323
|
+
Effect.flatMap((ok) =>
|
|
324
|
+
ok ? Effect.succeed(value) : Effect.fail("A rule by that id exists."),
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Excerpts are optional: an agent that cannot search the repo, such as codex
|
|
331
|
+
// without its sandbox, leaves the rule's examples to the draft.
|
|
332
|
+
const found = yield* askingFor(
|
|
333
|
+
agent,
|
|
334
|
+
"for code to judge",
|
|
335
|
+
root,
|
|
336
|
+
examplesPrompt(idea, id),
|
|
337
|
+
Candidates,
|
|
338
|
+
).pipe(
|
|
339
|
+
Effect.catch(({ message }) =>
|
|
340
|
+
warn(`No code from the repo: ${message}`).pipe(Effect.as({ candidates: [] })),
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
const reviewed = yield* review(agent, root, found.candidates);
|
|
344
|
+
|
|
345
|
+
const file = path.join(rules, id, ruleFile);
|
|
346
|
+
const branch = `adhere/${id}`;
|
|
347
|
+
const prompt = writePrompt(idea, id, shape, reviewed);
|
|
348
|
+
const draft = unfenced(yield* asking(agent, "to write the rule", root, prompt));
|
|
349
|
+
const draftProblems = yield* problemsOf(draft, file, shape.format);
|
|
350
|
+
const text =
|
|
351
|
+
draftProblems.length === 0
|
|
352
|
+
? draft
|
|
353
|
+
: unfenced(
|
|
354
|
+
yield* asking(
|
|
355
|
+
agent,
|
|
356
|
+
"to fix the rule",
|
|
357
|
+
root,
|
|
358
|
+
`${prompt}\n\nYour last draft:\n\n${draft}\n\nadhere found these problems with it:\n${draftProblems.map((problem) => `- ${problem}`).join("\n")}\n\nFix them, and reply with only the corrected file.`,
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
const problems = yield* problemsOf(text, file, shape.format);
|
|
362
|
+
|
|
363
|
+
yield* line(`\n${text}\n`);
|
|
364
|
+
for (const problem of problems) yield* warn(problem);
|
|
365
|
+
const write = yield* Prompt.run(
|
|
366
|
+
Prompt.Confirm({
|
|
367
|
+
message:
|
|
368
|
+
scope === "project"
|
|
369
|
+
? `Write ${path.relative(root, file)}?`
|
|
370
|
+
: `Push it to ${shared} on the branch ${branch}?`,
|
|
371
|
+
initial: problems.length === 0,
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
if (!write) return yield* outro("Nothing written.");
|
|
375
|
+
// A RULE.ts is only imported, and its code run, once the user has read it.
|
|
376
|
+
if (shape.format === "typescript") {
|
|
377
|
+
const loaded = yield* loadedProblems(text, path.relative(root, file));
|
|
378
|
+
for (const problem of loaded) yield* warn(problem);
|
|
379
|
+
if (
|
|
380
|
+
loaded.length > 0 &&
|
|
381
|
+
!(yield* Prompt.run(Prompt.Confirm({ message: "Write it anyway?", initial: false })))
|
|
382
|
+
) {
|
|
383
|
+
return yield* outro("Nothing written.");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
|
|
387
|
+
yield* fs.writeFileString(file, `${text}\n`);
|
|
388
|
+
|
|
389
|
+
if (scope === "project") {
|
|
390
|
+
const configFile = yield* findConfigFile(root).pipe(Effect.orElseSucceed(() => undefined));
|
|
391
|
+
const config =
|
|
392
|
+
configFile === undefined
|
|
393
|
+
? {}
|
|
394
|
+
: yield* loadConfigFile(path.join(root, configFile)).pipe(
|
|
395
|
+
Effect.orElseSucceed(() => ({})),
|
|
396
|
+
);
|
|
397
|
+
const needed = settingsFor(shape, config);
|
|
398
|
+
if (needed.length > 0) {
|
|
399
|
+
yield* warn(
|
|
400
|
+
`${id} needs ${needed.join(" and ")} in ${configFile ?? CONFIG_FILES[0]}${shape.typeAware ? " (or lint --type-aware)" : ""}. Until then, lint judges it without that context.`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return yield* outro(
|
|
404
|
+
`Wrote ${path.relative(root, file)}.\nRun adhere validate to check it against your other rules.`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const needed = settingsFor(shape, {});
|
|
408
|
+
const needs =
|
|
409
|
+
needed.length === 0
|
|
410
|
+
? []
|
|
411
|
+
: [`Repos that install it need ${needed.join(" and ")} in their adhere config.`];
|
|
412
|
+
yield* git(repo, ["switch", "--quiet", "--create", branch]);
|
|
413
|
+
yield* git(repo, ["add", "--", path.relative(repo, file)]);
|
|
414
|
+
yield* git(repo, [
|
|
415
|
+
"commit",
|
|
416
|
+
"--quiet",
|
|
417
|
+
"--message",
|
|
418
|
+
`Add rule ${id}`,
|
|
419
|
+
...needs.flatMap((body) => ["--message", body]),
|
|
420
|
+
]);
|
|
421
|
+
yield* git(repo, ["push", "--quiet", "origin", branch]);
|
|
422
|
+
yield* outro(
|
|
423
|
+
[
|
|
424
|
+
`Pushed ${branch} to ${shared}.`,
|
|
425
|
+
...needs,
|
|
426
|
+
`Open the pull request: https://github.com/${shared}/compare/${branch}?expand=1`,
|
|
427
|
+
].join("\n"),
|
|
428
|
+
);
|
|
429
|
+
},
|
|
430
|
+
Effect.scoped,
|
|
431
|
+
(effect) => Effect.provideService(effect, Prompt.Theme, Prompt.makeTheme(look)),
|
|
432
|
+
);
|
package/src/index.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -173,7 +173,8 @@ jobs:
|
|
|
173
173
|
runs-on: ubuntu-latest
|
|
174
174
|
steps:
|
|
175
175
|
- uses: actions/checkout@v4
|
|
176
|
-
- run:
|
|
176
|
+
- run: curl -fsSL https://github.com/darkmatter/adhere/raw/main/scripts/install.sh | sh
|
|
177
|
+
- run: ~/.local/bin/adhere validate
|
|
177
178
|
env:
|
|
178
179
|
TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
|
|
179
180
|
`;
|
package/src/install.ts
CHANGED
|
@@ -34,7 +34,7 @@ const failed = (what: string) => (cause: unknown) =>
|
|
|
34
34
|
* credentials git already has, and `url.<base>.insteadOf` can point
|
|
35
35
|
* github.com elsewhere, such as at SSH.
|
|
36
36
|
*/
|
|
37
|
-
const cloned = (source: Source) =>
|
|
37
|
+
export const cloned = (source: Source) =>
|
|
38
38
|
Effect.acquireRelease(
|
|
39
39
|
Effect.tryPromise({
|
|
40
40
|
try: () => mkdtemp(join(tmpdir(), "adhere-install-")),
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { findConfigFile, loadConfigFile } from "#services/AdhereConfig.ts";
|
|
2
|
+
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
const START = "<!-- adhere:instructions:start -->";
|
|
5
|
+
const END = "<!-- adhere:instructions:end -->";
|
|
6
|
+
|
|
7
|
+
class InstructionsUnavailable extends Schema.TaggedError<InstructionsUnavailable>()(
|
|
8
|
+
"InstructionsUnavailable",
|
|
9
|
+
{ message: Schema.String },
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
/** Update only adhere's marked block; leave all other AGENTS.md content untouched. */
|
|
13
|
+
export const renderInstructions = Effect.fn("renderInstructions")(function* (root: string) {
|
|
14
|
+
const fs = yield* FileSystem.FileSystem;
|
|
15
|
+
const path = yield* Path.Path;
|
|
16
|
+
const configFile = yield* findConfigFile(root);
|
|
17
|
+
const config =
|
|
18
|
+
configFile === undefined ? undefined : yield* loadConfigFile(path.join(root, configFile));
|
|
19
|
+
const candidates =
|
|
20
|
+
config?.instructions === undefined
|
|
21
|
+
? [path.join(root, ".adhere/instructions"), path.join(root, ".agents/instructions")]
|
|
22
|
+
: [path.resolve(root, path.dirname(configFile ?? ""), config.instructions)];
|
|
23
|
+
let directory: string | undefined;
|
|
24
|
+
for (const candidate of candidates) {
|
|
25
|
+
if (yield* fs.exists(candidate)) {
|
|
26
|
+
directory = candidate;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (directory === undefined) {
|
|
31
|
+
return `Skipped AGENTS.md: no instructions directory (${candidates.map((candidate) => path.relative(root, candidate)).join(", ")}).`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const names = (yield* fs.readDirectory(directory)).filter((name) => name.endsWith(".md")).sort();
|
|
35
|
+
const contents: string[] = [];
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
const file = path.join(directory, name);
|
|
38
|
+
if ((yield* fs.stat(file)).type === "File") {
|
|
39
|
+
const text = (yield* fs.readFileString(file)).trim();
|
|
40
|
+
if (text.includes(START) || text.includes(END)) {
|
|
41
|
+
return yield* InstructionsUnavailable.make({
|
|
42
|
+
message: `${file} contains reserved adhere instruction markers.`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (text !== "") contents.push(text);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const target = path.join(root, "AGENTS.md");
|
|
49
|
+
const previous = yield* fs
|
|
50
|
+
.readFileString(target)
|
|
51
|
+
.pipe(
|
|
52
|
+
Effect.catchTag("PlatformError", (error) =>
|
|
53
|
+
error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
const start = previous.indexOf(START);
|
|
57
|
+
const end = previous.indexOf(END);
|
|
58
|
+
if (
|
|
59
|
+
(start === -1) !== (end === -1) ||
|
|
60
|
+
(start !== -1 &&
|
|
61
|
+
(end < start ||
|
|
62
|
+
previous.indexOf(START, start + START.length) !== -1 ||
|
|
63
|
+
previous.indexOf(END, end + END.length) !== -1))
|
|
64
|
+
) {
|
|
65
|
+
return yield* InstructionsUnavailable.make({
|
|
66
|
+
message: `${target} has malformed or duplicate adhere instruction markers. Fix them before running postinstall.`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const block = [START, ...contents, END].join("\n\n");
|
|
70
|
+
const next =
|
|
71
|
+
start === -1
|
|
72
|
+
? `${previous}${previous === "" ? "" : previous.endsWith("\n\n") ? "" : previous.endsWith("\n") ? "\n" : "\n\n"}${block}\n`
|
|
73
|
+
: `${previous.slice(0, start)}${block}${previous.slice(end + END.length)}`;
|
|
74
|
+
if (next !== previous) yield* fs.writeFileString(target, next);
|
|
75
|
+
return `${next === previous ? "Unchanged" : "Updated"} AGENTS.md: ${contents.length} instruction ${contents.length === 1 ? "file" : "files"} from ${path.relative(root, directory) || "."}.`;
|
|
76
|
+
});
|
package/src/markdown.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { Effect, Schema } from "effect";
|
|
|
28
28
|
* fence's own word wins over its heading's. The first fence under each word is
|
|
29
29
|
* that example, and a rule has either or both. An untagged fence outside those
|
|
30
30
|
* sections is the code to write, and without a fence the whole body is.
|
|
31
|
-
* `
|
|
31
|
+
* `threshold` is optional, and
|
|
32
32
|
* so is `tests`: `only` for a rule about test files, which rules otherwise
|
|
33
33
|
* skip, and `include` for a rule that judges them too. So is `level`:
|
|
34
34
|
* `warning` reports the rule's findings as warnings, which do not fail a run.
|
|
@@ -85,11 +85,11 @@ interface Heading {
|
|
|
85
85
|
readonly word: Word | undefined;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** `must not`, like `never
|
|
88
|
+
/** `must not`, like `never`, names code never to write. */
|
|
89
89
|
const wordOf = (info: string): Word | undefined => {
|
|
90
90
|
const words = info.trim().toLowerCase().split(/\s+/);
|
|
91
91
|
const not = words.includes("not");
|
|
92
|
-
if (words.includes("never")
|
|
92
|
+
if (words.includes("never")) return "never";
|
|
93
93
|
if (words.includes("should")) return not ? "should not" : "should";
|
|
94
94
|
if (words.includes("must")) return not ? "never" : "must";
|
|
95
95
|
return undefined;
|
|
@@ -99,7 +99,6 @@ const HEADING_WORDS: ReadonlySet<string> = new Set([
|
|
|
99
99
|
"must",
|
|
100
100
|
"must not",
|
|
101
101
|
"never",
|
|
102
|
-
"avoid",
|
|
103
102
|
"should",
|
|
104
103
|
"should not",
|
|
105
104
|
]);
|
|
@@ -208,11 +207,6 @@ export const parseRuleMarkdown = (
|
|
|
208
207
|
});
|
|
209
208
|
}
|
|
210
209
|
const fields = parseFrontMatter(match[1]);
|
|
211
|
-
if (Object.hasOwn(fields, "reads")) {
|
|
212
|
-
return yield* ConfigUnavailable.make({
|
|
213
|
-
message: `${file}: move reads from the rule's front matter to the top level of defineConfig({...}) in your config file.`,
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
210
|
const front = yield* Schema.decodeUnknownEffect(FrontMatter)(fields).pipe(
|
|
217
211
|
Effect.mapError((problem) =>
|
|
218
212
|
ConfigUnavailable.make({ message: `${file}: ${problem.message}` }),
|