@drkmttr/adhere 0.18.0 → 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.
@@ -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
@@ -37,3 +37,5 @@ export {
37
37
  type RuleEntry,
38
38
  type RuleSet,
39
39
  } from "#rules.ts";
40
+ /** For a `RULE.ts`'s `options`: the executable resolves no `effect` of the repo's own. */
41
+ export { Schema } from "effect";
package/src/init.ts CHANGED
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, relative } from "node:path";
4
4
  import { CONFIG_FILES } from "#config.ts";
5
- import { Effect } from "effect";
5
+ import { Effect, Schema } from "effect";
6
6
 
7
7
  export interface InitResult {
8
8
  readonly created: ReadonlyArray<string>;
@@ -173,7 +173,8 @@ jobs:
173
173
  runs-on: ubuntu-latest
174
174
  steps:
175
175
  - uses: actions/checkout@v4
176
- - run: npx --yes @drkmttr/adhere validate
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
  `;
@@ -305,6 +306,13 @@ const writeScaffoldFile = (root: string, path: string, contents: string, options
305
306
 
306
307
  const PACKAGE = "@drkmttr/adhere";
307
308
 
309
+ const PackageManifest = Schema.fromJsonString(
310
+ Schema.Struct({
311
+ dependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
312
+ devDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
313
+ }),
314
+ );
315
+
308
316
  type PackageManager = "bun" | "pnpm" | "yarn" | "npm";
309
317
 
310
318
  /** The first lockfile at the root names the package manager; npm when there is none. */
@@ -342,9 +350,21 @@ const addFlags: Record<PackageManager, ReadonlyArray<string>> = {
342
350
  const installPackages = (root: string, shared: boolean) =>
343
351
  Effect.tryPromise({
344
352
  try: async (): Promise<Install> => {
345
- const manifest = await readFile(join(root, "package.json"), "utf8").catch(() => undefined);
353
+ const manifest = await readFile(join(root, "package.json"), "utf8").catch(
354
+ (cause: unknown) => {
355
+ if (
356
+ typeof cause === "object" &&
357
+ cause !== null &&
358
+ "code" in cause &&
359
+ cause.code === "ENOENT"
360
+ ) {
361
+ return undefined;
362
+ }
363
+ throw cause;
364
+ },
365
+ );
346
366
  if (manifest === undefined) return { status: "no-package-json" };
347
- const { dependencies, devDependencies } = JSON.parse(manifest);
367
+ const { dependencies, devDependencies } = Schema.decodeUnknownSync(PackageManifest)(manifest);
348
368
  if (
349
369
  !shared &&
350
370
  (dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined)
package/src/install.ts CHANGED
@@ -34,33 +34,38 @@ 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
- try: async () => {
41
- const directory = await mkdtemp(join(tmpdir(), "adhere-install-"));
42
- const branch = source.ref === undefined ? [] : ["--branch", source.ref];
43
- const url = `https://github.com/${source.repo}.git`;
44
- await new Promise<void>((resolve, reject) => {
45
- let stderr = "";
46
- spawn("git", ["clone", "--quiet", "--depth", "1", ...branch, url, directory], {
47
- stdio: ["inherit", "ignore", "pipe"],
48
- })
49
- .on("error", reject)
50
- .on("exit", (code) =>
51
- code === 0
52
- ? resolve()
53
- : reject(new Error(stderr.trim() || `git exited with ${code}`)),
54
- )
55
- .stderr?.on("data", (chunk) => {
56
- stderr += chunk;
57
- });
58
- });
59
- return directory;
60
- },
61
- catch: failed(`Could not clone ${source.repo}`),
40
+ try: () => mkdtemp(join(tmpdir(), "adhere-install-")),
41
+ catch: failed(`Could not create a temporary directory for ${source.repo}`),
62
42
  }),
63
43
  (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
44
+ ).pipe(
45
+ Effect.tap((directory) =>
46
+ Effect.tryPromise({
47
+ try: async () => {
48
+ const branch = source.ref === undefined ? [] : ["--branch", source.ref];
49
+ const url = `https://github.com/${source.repo}.git`;
50
+ await new Promise<void>((resolve, reject) => {
51
+ let stderr = "";
52
+ spawn("git", ["clone", "--quiet", "--depth", "1", ...branch, url, directory], {
53
+ stdio: ["inherit", "ignore", "pipe"],
54
+ })
55
+ .on("error", reject)
56
+ .on("exit", (code) =>
57
+ code === 0
58
+ ? resolve()
59
+ : reject(new Error(stderr.trim() || `git exited with ${code}`)),
60
+ )
61
+ .stderr?.on("data", (chunk) => {
62
+ stderr += chunk;
63
+ });
64
+ });
65
+ },
66
+ catch: failed(`Could not clone ${source.repo}`),
67
+ }),
68
+ ),
64
69
  );
65
70
 
66
71
  /**
@@ -103,7 +108,8 @@ export const listRules = (source: Source) =>
103
108
  const rules = yield* rulesIn(source, yield* cloned(source));
104
109
  return yield* Effect.forEach(rules, ({ id, name, directory }) =>
105
110
  Effect.gen(function* () {
106
- if (name === "RULE.ts") return { id, description: "(TypeScript, which list does not run)" };
111
+ if (name === "RULE.ts")
112
+ return { id, description: "(TypeScript, which list does not run)" };
107
113
  const text = yield* Effect.tryPromise({
108
114
  try: () => readFile(join(directory, name), "utf8"),
109
115
  catch: failed(`Could not read ${id}`),
@@ -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
- * `avoid`, the word before 0.7, means `never`. `threshold` is optional, and
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` and the older `avoid`, names code never to write. */
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") || words.includes("avoid")) return "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}` }),