@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.
@@ -11,33 +11,34 @@
11
11
  import { CONFIG_FILES, DEFAULT_READS } from "#config.ts";
12
12
  import { AGENTS, type Agent, installedAgents, run } from "#create/agents.ts";
13
13
  import {
14
- type Candidate,
15
- Candidates,
16
- type Reviewed,
17
14
  RuleNotCreated,
18
15
  Shape,
16
+ adhereRevisionPrompt,
19
17
  asking,
20
18
  askingFor,
21
- examplesPrompt,
22
19
  shapePrompt,
23
20
  unfenced,
24
21
  writePrompt,
25
22
  } from "#create/ask.ts";
23
+ import { reviewedInRepository } from "#create/examples.ts";
24
+ import { createOxlintRule } from "#create/oxlint.ts";
25
+ import { trialAndReview, trialStatus, trialWriteSuffix } from "#create/trial-review.ts";
26
+ import { oxlintRule } from "#oxlint/catalog.ts";
26
27
  import {
27
28
  decided,
28
- excerpt,
29
29
  indented,
30
30
  intro,
31
31
  line,
32
32
  look,
33
33
  note,
34
34
  outro,
35
- said,
35
+ renderMarkdown,
36
36
  spinning,
37
37
  warn,
38
38
  } from "#create/look.ts";
39
39
  import { cloned } from "#install.ts";
40
- import { parseRuleMarkdown } from "#markdown.ts";
40
+ import { parseRuleMarkdown, ruleBodyOf } from "#markdown.ts";
41
+ import { isSharedRepo, refreshIndexIfShared, refreshReadme, validateReadme } from "#readme.ts";
41
42
  import { importRule } from "#rules.ts";
42
43
  import { findConfigFile, loadConfigFile } from "#services/AdhereConfig.ts";
43
44
  import { adhereConfigDirectory } from "#services/Credentials.ts";
@@ -112,58 +113,6 @@ const failed = (cause: unknown) =>
112
113
  const git = (cwd: string, args: ReadonlyArray<string>) =>
113
114
  Effect.tryPromise({ try: () => run("git", args, cwd, ""), catch: failed });
114
115
 
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
116
  /**
168
117
  * What adhere can say of a draft without running it: for a RULE.md, what
169
118
  * `validate` would; for a RULE.ts, whether it parses and has a default export.
@@ -184,11 +133,31 @@ const problemsOf = (text: string, file: string, format: Shape["format"]) =>
184
133
  Effect.catch((message) => Effect.succeed([`It does not parse: ${message}`])),
185
134
  );
186
135
 
136
+ /**
137
+ * Shows a draft as the user reads it: a RULE.md rendered from Markdown with
138
+ * its frontmatter and source intact, a RULE.ts as written, either on the rail.
139
+ * Only the preview is rendered; the draft itself stays raw for the agent, the
140
+ * trial, and the file.
141
+ */
142
+ const showDraft = (text: string, format: Shape["format"]) =>
143
+ Effect.gen(function* () {
144
+ if (format !== "markdown") return yield* line(`\n${text}\n`);
145
+ const body = ruleBodyOf(text);
146
+ const frontmatter = text.slice(0, text.length - body.length);
147
+ yield* line(`\n${frontmatter}${yield* renderMarkdown(body)}\n`);
148
+ });
149
+
150
+ /** A RULE.ts that imported, with its wording strays, or one that did not import. */
151
+ export type LoadedDraft =
152
+ | { readonly _tag: "Loaded"; readonly warnings: ReadonlyArray<string> }
153
+ | { readonly _tag: "Unavailable"; readonly problems: ReadonlyArray<string> };
154
+
187
155
  /**
188
156
  * What `validate` would say of a RULE.ts the user has approved: imported from
189
- * a temporary directory, so its code runs.
157
+ * a temporary directory, so its code runs. A wording stray is a warning about
158
+ * a rule that still runs; only a failure to import makes it untriallable.
190
159
  */
191
- const loadedProblems = (text: string, shown: string) =>
160
+ export const loadedDraft = (text: string, shown: string) =>
192
161
  Effect.gen(function* () {
193
162
  const fs = yield* FileSystem.FileSystem;
194
163
  const path = yield* Path.Path;
@@ -198,16 +167,45 @@ const loadedProblems = (text: string, shown: string) =>
198
167
  );
199
168
  yield* fs.writeFileString(file, text);
200
169
  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)]),
170
+ Effect.map((rule): LoadedDraft => ({
171
+ _tag: "Loaded",
172
+ warnings: strayingOf(rule).map(sentenceOf),
173
+ })),
174
+ Effect.catchTag("ConfigUnavailable", ({ message }): Effect.Effect<LoadedDraft> =>
175
+ Effect.succeed({
176
+ _tag: "Unavailable",
177
+ problems: [message.replaceAll(file, shown)],
178
+ }),
204
179
  ),
205
180
  );
206
181
  });
207
182
 
183
+ /**
184
+ * Whether the trial may import and run the draft: a RULE.md has no code, while
185
+ * a RULE.ts is imported only after the user approves it.
186
+ */
187
+ const approveDraft = (text: string, format: Shape["format"], shown: string) =>
188
+ Effect.gen(function* () {
189
+ if (format === "markdown") return { _tag: "Approved", draft: text } as const;
190
+ const run = yield* Prompt.run(
191
+ Prompt.Confirm({
192
+ message: "adhere will import this RULE.ts and run its code to trial it. Run it?",
193
+ initial: true,
194
+ }),
195
+ );
196
+ if (!run) return { _tag: "Declined" } as const;
197
+ const loaded = yield* loadedDraft(text, shown);
198
+ if (loaded._tag === "Unavailable") {
199
+ return { _tag: "Unavailable", problems: loaded.problems } as const;
200
+ }
201
+ for (const warning of loaded.warnings) yield* warn(warning);
202
+ return { _tag: "Approved", draft: text } as const;
203
+ });
204
+
208
205
  /**
209
206
  * 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.
207
+ * written in it. The rule declares its reads, but the config still enables
208
+ * them and type awareness.
211
209
  */
212
210
  const settingsFor = (
213
211
  shape: Shape,
@@ -249,14 +247,23 @@ export const createRule = Effect.fn("createRule")(
249
247
  : yield* Prompt.run(Prompt.String({ message: "What should the rule enforce?" }));
250
248
 
251
249
  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,
250
+ const native = shape.linter === null ? undefined : oxlintRule(shape.linter);
251
+ if (native !== undefined) {
252
+ return yield* createOxlintRule({ agent, idea, shape, root, shared });
253
+ }
254
+ if (shape.mechanical && !shape.typeAware) {
255
+ const approach = yield* Prompt.run(
256
+ Prompt.Select({
257
+ message: "A custom oxlint JS plugin can check this. Which do you want?",
258
+ choices: [
259
+ { title: "Write an oxlint plugin", value: "plugin" as const },
260
+ { title: "Write an adhere rule", value: "adhere" as const },
261
+ ],
257
262
  }),
258
263
  );
259
- if (!anyway) return yield* outro("Nothing written.");
264
+ if (approach === "plugin") {
265
+ return yield* createOxlintRule({ agent, idea, shape, root, shared });
266
+ }
260
267
  }
261
268
  const ruleFile = shape.format === "typescript" ? "RULE.ts" : "RULE.md";
262
269
  yield* decided(`A ${ruleFile}`, shape.formatReason);
@@ -327,20 +334,7 @@ export const createRule = Effect.fn("createRule")(
327
334
  }),
328
335
  );
329
336
 
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);
337
+ const reviewed = yield* reviewedInRepository(agent, root, idea, id);
344
338
 
345
339
  const file = path.join(rules, id, ruleFile);
346
340
  const branch = `adhere/${id}`;
@@ -360,31 +354,66 @@ export const createRule = Effect.fn("createRule")(
360
354
  );
361
355
  const problems = yield* problemsOf(text, file, shape.format);
362
356
 
363
- yield* line(`\n${text}\n`);
357
+ yield* showDraft(text, shape.format);
364
358
  for (const problem of problems) yield* warn(problem);
359
+ const shown = path.relative(root, file);
360
+ const outcome = yield* trialAndReview({
361
+ agent,
362
+ root,
363
+ id,
364
+ draft: text,
365
+ toDraft: (source) => ({ kind: "adhere", id, format: shape.format, source }),
366
+ approve: (source) => approveDraft(source, shape.format, shown),
367
+ revise: (source, evidence) =>
368
+ Effect.gen(function* () {
369
+ const revised = unfenced(
370
+ yield* asking(
371
+ agent,
372
+ "to revise the rule",
373
+ root,
374
+ adhereRevisionPrompt(idea, id, shape, source, evidence),
375
+ ),
376
+ );
377
+ for (const problem of yield* problemsOf(revised, file, shape.format)) {
378
+ yield* warn(problem);
379
+ }
380
+ return revised;
381
+ }),
382
+ show: (source) => showDraft(source, shape.format),
383
+ });
384
+ if (outcome._tag === "Cancelled") return yield* outro("Nothing written.");
385
+ const finalText = outcome.draft;
386
+ const finalProblems = yield* problemsOf(finalText, file, shape.format);
387
+ if (finalText !== text) {
388
+ yield* showDraft(finalText, shape.format);
389
+ for (const problem of finalProblems) yield* warn(problem);
390
+ }
365
391
  const write = yield* Prompt.run(
366
392
  Prompt.Confirm({
367
393
  message:
368
- scope === "project"
394
+ (scope === "project"
369
395
  ? `Write ${path.relative(root, file)}?`
370
- : `Push it to ${shared} on the branch ${branch}?`,
371
- initial: problems.length === 0,
396
+ : `Push it to ${shared} on the branch ${branch}?`) + trialWriteSuffix(outcome),
397
+ initial: finalProblems.length === 0 && outcome._tag === "Accepted",
372
398
  }),
373
399
  );
374
400
  if (!write) return yield* outro("Nothing written.");
375
401
  // A RULE.ts is only imported, and its code run, once the user has read it.
376
402
  if (shape.format === "typescript") {
377
- const loaded = yield* loadedProblems(text, path.relative(root, file));
378
- for (const problem of loaded) yield* warn(problem);
403
+ const loaded = yield* loadedDraft(finalText, path.relative(root, file));
404
+ const problems = loaded._tag === "Loaded" ? loaded.warnings : loaded.problems;
405
+ for (const problem of problems) yield* warn(problem);
379
406
  if (
380
- loaded.length > 0 &&
407
+ problems.length > 0 &&
381
408
  !(yield* Prompt.run(Prompt.Confirm({ message: "Write it anyway?", initial: false })))
382
409
  ) {
383
410
  return yield* outro("Nothing written.");
384
411
  }
385
412
  }
413
+ const sharedHere = scope === "project" ? yield* isSharedRepo(root) : false;
414
+ if (sharedHere) yield* validateReadme(root);
386
415
  yield* fs.makeDirectory(path.dirname(file), { recursive: true });
387
- yield* fs.writeFileString(file, `${text}\n`);
416
+ yield* fs.writeFileString(file, `${finalText}\n`);
388
417
 
389
418
  if (scope === "project") {
390
419
  const configFile = yield* findConfigFile(root).pipe(Effect.orElseSucceed(() => undefined));
@@ -400,8 +429,9 @@ export const createRule = Effect.fn("createRule")(
400
429
  `${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
430
  );
402
431
  }
432
+ const refreshed = sharedHere ? yield* refreshReadme(root) : undefined;
403
433
  return yield* outro(
404
- `Wrote ${path.relative(root, file)}.\nRun adhere validate to check it against your other rules.`,
434
+ `Wrote ${path.relative(root, file)}.\n${trialStatus(outcome)}\n${refreshed === undefined ? "" : `${refreshed.message}\n`}Run adhere validate to check it against your other rules.`,
405
435
  );
406
436
  }
407
437
  const needed = settingsFor(shape, {});
@@ -409,8 +439,13 @@ export const createRule = Effect.fn("createRule")(
409
439
  needed.length === 0
410
440
  ? []
411
441
  : [`Repos that install it need ${needed.join(" and ")} in their adhere config.`];
442
+ // A shared repo's index joins the rule in one commit and one pull request;
443
+ // a legacy rules-only repo has no index to refresh.
444
+ const indexed = yield* refreshIndexIfShared(repo);
412
445
  yield* git(repo, ["switch", "--quiet", "--create", branch]);
413
- yield* git(repo, ["add", "--", path.relative(repo, file)]);
446
+ const staged = [path.relative(repo, file)];
447
+ if (indexed) staged.push("README.md");
448
+ yield* git(repo, ["add", "--", ...staged]);
414
449
  yield* git(repo, [
415
450
  "commit",
416
451
  "--quiet",
@@ -422,6 +457,7 @@ export const createRule = Effect.fn("createRule")(
422
457
  yield* outro(
423
458
  [
424
459
  `Pushed ${branch} to ${shared}.`,
460
+ trialStatus(outcome),
425
461
  ...needs,
426
462
  `Open the pull request: https://github.com/${shared}/compare/${branch}?expand=1`,
427
463
  ].join("\n"),
package/src/filters.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { Rule, RuleFilter } from "#config.ts";
2
+ import { matchesGlob } from "node:path";
3
+
4
+ /** A repository path as every matcher here reads it: `/`-separated, without a leading `./`. */
5
+ export const normalizedPath = (path: string): string =>
6
+ path.replaceAll("\\", "/").replace(/^\.\//, "");
7
+
8
+ interface CompiledSide {
9
+ readonly paths: ReadonlyArray<string>;
10
+ readonly content: ReadonlyArray<RegExp>;
11
+ }
12
+
13
+ interface CompiledFilters {
14
+ readonly include: CompiledSide;
15
+ readonly exclude: CompiledSide;
16
+ }
17
+
18
+ const compiled = new WeakMap<Pick<Rule, "include" | "exclude">, CompiledFilters>();
19
+
20
+ const compileSide = (filter: RuleFilter | undefined): CompiledSide => ({
21
+ paths: (filter?.paths ?? []).map(normalizedPath),
22
+ // Validated when the rule was decoded, so this cannot throw here.
23
+ content: (filter?.content ?? []).map((source) => new RegExp(source)),
24
+ });
25
+
26
+ /**
27
+ * A rule's filters, compiled once per rule: its globs with `/` separators, and
28
+ * its content patterns as regular expressions. Both sides are kept, since
29
+ * `include` and `exclude` differ only in what a match means.
30
+ */
31
+ export const compiledFiltersOf = (rule: Pick<Rule, "include" | "exclude">): CompiledFilters => {
32
+ const held = compiled.get(rule);
33
+ if (held !== undefined) return held;
34
+ const filters = { include: compileSide(rule.include), exclude: compileSide(rule.exclude) };
35
+ compiled.set(rule, filters);
36
+ return filters;
37
+ };
38
+
39
+ /** Whether a rule has either filter, so a run can leave unfiltered rules untouched. */
40
+ export const hasFilters = (rule: Pick<Rule, "include" | "exclude">): boolean =>
41
+ rule.include !== undefined || rule.exclude !== undefined;
42
+
43
+ /** Whether a rule's content patterns, if any, need the file's text to decide. */
44
+ export const filtersNeedText = (rule: Pick<Rule, "include" | "exclude">): boolean => {
45
+ const { include, exclude } = compiledFiltersOf(rule);
46
+ return include.content.length > 0 || exclude.content.length > 0;
47
+ };
48
+
49
+ const matchesAnyPath = (path: string, patterns: ReadonlyArray<string>): boolean =>
50
+ patterns.some((pattern) => matchesGlob(path, pattern));
51
+
52
+ /**
53
+ * Whether a repository-relative path passes a rule's path filters alone: an
54
+ * `include.paths` list must match when it has entries, and any
55
+ * `exclude.paths` match drops the file even when it matched.
56
+ */
57
+ export const pathsPass = (rule: Rule, relativePath: string): boolean => {
58
+ const { include, exclude } = compiledFiltersOf(rule);
59
+ const path = normalizedPath(relativePath);
60
+ return (
61
+ (include.paths.length === 0 || matchesAnyPath(path, include.paths)) &&
62
+ !(exclude.paths.length > 0 && matchesAnyPath(path, exclude.paths))
63
+ );
64
+ };
65
+
66
+ /**
67
+ * Whether a rule's content filters allow `text`, the file as written: any
68
+ * `include` entry matches, and no `exclude` entry does. A path fix for one
69
+ * file cannot change between planning and execution, so reloading a file
70
+ * rechecks only this half.
71
+ */
72
+ export const contentPasses = (rule: Rule, text: string): boolean => {
73
+ const { include, exclude } = compiledFiltersOf(rule);
74
+ return (
75
+ (include.content.length === 0 || include.content.some((pattern) => pattern.test(text))) &&
76
+ !(exclude.content.length > 0 && exclude.content.some((pattern) => pattern.test(text)))
77
+ );
78
+ };
79
+
80
+ /**
81
+ * Whether a rule applies to a file at a repository-relative path whose text
82
+ * is `text`: `pathsPass` and `contentPasses`. An `include` category that is
83
+ * set must match, while a match in any `exclude` category drops the file.
84
+ */
85
+ export const ruleAppliesTo = (rule: Rule, relativePath: string, text: string): boolean =>
86
+ pathsPass(rule, relativePath) && contentPasses(rule, text);