@drkmttr/adhere 0.21.2 → 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.
@@ -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
40
  import { parseRuleMarkdown } 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,29 @@ 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
+ const shown = format === "markdown" ? yield* renderMarkdown(text) : text;
145
+ yield* line(`\n${shown}\n`);
146
+ });
147
+
148
+ /** A RULE.ts that imported, with its wording strays, or one that did not import. */
149
+ export type LoadedDraft =
150
+ | { readonly _tag: "Loaded"; readonly warnings: ReadonlyArray<string> }
151
+ | { readonly _tag: "Unavailable"; readonly problems: ReadonlyArray<string> };
152
+
187
153
  /**
188
154
  * What `validate` would say of a RULE.ts the user has approved: imported from
189
- * a temporary directory, so its code runs.
155
+ * a temporary directory, so its code runs. A wording stray is a warning about
156
+ * a rule that still runs; only a failure to import makes it untriallable.
190
157
  */
191
- const loadedProblems = (text: string, shown: string) =>
158
+ export const loadedDraft = (text: string, shown: string) =>
192
159
  Effect.gen(function* () {
193
160
  const fs = yield* FileSystem.FileSystem;
194
161
  const path = yield* Path.Path;
@@ -198,16 +165,45 @@ const loadedProblems = (text: string, shown: string) =>
198
165
  );
199
166
  yield* fs.writeFileString(file, text);
200
167
  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)]),
168
+ Effect.map((rule): LoadedDraft => ({
169
+ _tag: "Loaded",
170
+ warnings: strayingOf(rule).map(sentenceOf),
171
+ })),
172
+ Effect.catchTag("ConfigUnavailable", ({ message }): Effect.Effect<LoadedDraft> =>
173
+ Effect.succeed({
174
+ _tag: "Unavailable",
175
+ problems: [message.replaceAll(file, shown)],
176
+ }),
204
177
  ),
205
178
  );
206
179
  });
207
180
 
181
+ /**
182
+ * Whether the trial may import and run the draft: a RULE.md has no code, while
183
+ * a RULE.ts is imported only after the user approves it.
184
+ */
185
+ const approveDraft = (text: string, format: Shape["format"], shown: string) =>
186
+ Effect.gen(function* () {
187
+ if (format === "markdown") return { _tag: "Approved", draft: text } as const;
188
+ const run = yield* Prompt.run(
189
+ Prompt.Confirm({
190
+ message: "adhere will import this RULE.ts and run its code to trial it. Run it?",
191
+ initial: true,
192
+ }),
193
+ );
194
+ if (!run) return { _tag: "Declined" } as const;
195
+ const loaded = yield* loadedDraft(text, shown);
196
+ if (loaded._tag === "Unavailable") {
197
+ return { _tag: "Unavailable", problems: loaded.problems } as const;
198
+ }
199
+ for (const warning of loaded.warnings) yield* warn(warning);
200
+ return { _tag: "Approved", draft: text } as const;
201
+ });
202
+
208
203
  /**
209
204
  * 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.
205
+ * written in it. The rule declares its reads, but the config still enables
206
+ * them and type awareness.
211
207
  */
212
208
  const settingsFor = (
213
209
  shape: Shape,
@@ -249,14 +245,23 @@ export const createRule = Effect.fn("createRule")(
249
245
  : yield* Prompt.run(Prompt.String({ message: "What should the rule enforce?" }));
250
246
 
251
247
  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,
248
+ const native = shape.linter === null ? undefined : oxlintRule(shape.linter);
249
+ if (native !== undefined) {
250
+ return yield* createOxlintRule({ agent, idea, shape, root, shared });
251
+ }
252
+ if (shape.mechanical && !shape.typeAware) {
253
+ const approach = yield* Prompt.run(
254
+ Prompt.Select({
255
+ message: "A custom oxlint JS plugin can check this. Which do you want?",
256
+ choices: [
257
+ { title: "Write an oxlint plugin", value: "plugin" as const },
258
+ { title: "Write an adhere rule", value: "adhere" as const },
259
+ ],
257
260
  }),
258
261
  );
259
- if (!anyway) return yield* outro("Nothing written.");
262
+ if (approach === "plugin") {
263
+ return yield* createOxlintRule({ agent, idea, shape, root, shared });
264
+ }
260
265
  }
261
266
  const ruleFile = shape.format === "typescript" ? "RULE.ts" : "RULE.md";
262
267
  yield* decided(`A ${ruleFile}`, shape.formatReason);
@@ -327,20 +332,7 @@ export const createRule = Effect.fn("createRule")(
327
332
  }),
328
333
  );
329
334
 
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);
335
+ const reviewed = yield* reviewedInRepository(agent, root, idea, id);
344
336
 
345
337
  const file = path.join(rules, id, ruleFile);
346
338
  const branch = `adhere/${id}`;
@@ -360,31 +352,66 @@ export const createRule = Effect.fn("createRule")(
360
352
  );
361
353
  const problems = yield* problemsOf(text, file, shape.format);
362
354
 
363
- yield* line(`\n${text}\n`);
355
+ yield* showDraft(text, shape.format);
364
356
  for (const problem of problems) yield* warn(problem);
357
+ const shown = path.relative(root, file);
358
+ const outcome = yield* trialAndReview({
359
+ agent,
360
+ root,
361
+ id,
362
+ draft: text,
363
+ toDraft: (source) => ({ kind: "adhere", id, format: shape.format, source }),
364
+ approve: (source) => approveDraft(source, shape.format, shown),
365
+ revise: (source, evidence) =>
366
+ Effect.gen(function* () {
367
+ const revised = unfenced(
368
+ yield* asking(
369
+ agent,
370
+ "to revise the rule",
371
+ root,
372
+ adhereRevisionPrompt(idea, id, shape, source, evidence),
373
+ ),
374
+ );
375
+ for (const problem of yield* problemsOf(revised, file, shape.format)) {
376
+ yield* warn(problem);
377
+ }
378
+ return revised;
379
+ }),
380
+ show: (source) => showDraft(source, shape.format),
381
+ });
382
+ if (outcome._tag === "Cancelled") return yield* outro("Nothing written.");
383
+ const finalText = outcome.draft;
384
+ const finalProblems = yield* problemsOf(finalText, file, shape.format);
385
+ if (finalText !== text) {
386
+ yield* showDraft(finalText, shape.format);
387
+ for (const problem of finalProblems) yield* warn(problem);
388
+ }
365
389
  const write = yield* Prompt.run(
366
390
  Prompt.Confirm({
367
391
  message:
368
- scope === "project"
392
+ (scope === "project"
369
393
  ? `Write ${path.relative(root, file)}?`
370
- : `Push it to ${shared} on the branch ${branch}?`,
371
- initial: problems.length === 0,
394
+ : `Push it to ${shared} on the branch ${branch}?`) + trialWriteSuffix(outcome),
395
+ initial: finalProblems.length === 0 && outcome._tag === "Accepted",
372
396
  }),
373
397
  );
374
398
  if (!write) return yield* outro("Nothing written.");
375
399
  // A RULE.ts is only imported, and its code run, once the user has read it.
376
400
  if (shape.format === "typescript") {
377
- const loaded = yield* loadedProblems(text, path.relative(root, file));
378
- for (const problem of loaded) yield* warn(problem);
401
+ const loaded = yield* loadedDraft(finalText, path.relative(root, file));
402
+ const problems = loaded._tag === "Loaded" ? loaded.warnings : loaded.problems;
403
+ for (const problem of problems) yield* warn(problem);
379
404
  if (
380
- loaded.length > 0 &&
405
+ problems.length > 0 &&
381
406
  !(yield* Prompt.run(Prompt.Confirm({ message: "Write it anyway?", initial: false })))
382
407
  ) {
383
408
  return yield* outro("Nothing written.");
384
409
  }
385
410
  }
411
+ const sharedHere = scope === "project" ? yield* isSharedRepo(root) : false;
412
+ if (sharedHere) yield* validateReadme(root);
386
413
  yield* fs.makeDirectory(path.dirname(file), { recursive: true });
387
- yield* fs.writeFileString(file, `${text}\n`);
414
+ yield* fs.writeFileString(file, `${finalText}\n`);
388
415
 
389
416
  if (scope === "project") {
390
417
  const configFile = yield* findConfigFile(root).pipe(Effect.orElseSucceed(() => undefined));
@@ -400,8 +427,9 @@ export const createRule = Effect.fn("createRule")(
400
427
  `${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
428
  );
402
429
  }
430
+ const refreshed = sharedHere ? yield* refreshReadme(root) : undefined;
403
431
  return yield* outro(
404
- `Wrote ${path.relative(root, file)}.\nRun adhere validate to check it against your other rules.`,
432
+ `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
433
  );
406
434
  }
407
435
  const needed = settingsFor(shape, {});
@@ -409,8 +437,13 @@ export const createRule = Effect.fn("createRule")(
409
437
  needed.length === 0
410
438
  ? []
411
439
  : [`Repos that install it need ${needed.join(" and ")} in their adhere config.`];
440
+ // A shared repo's index joins the rule in one commit and one pull request;
441
+ // a legacy rules-only repo has no index to refresh.
442
+ const indexed = yield* refreshIndexIfShared(repo);
412
443
  yield* git(repo, ["switch", "--quiet", "--create", branch]);
413
- yield* git(repo, ["add", "--", path.relative(repo, file)]);
444
+ const staged = [path.relative(repo, file)];
445
+ if (indexed) staged.push("README.md");
446
+ yield* git(repo, ["add", "--", ...staged]);
414
447
  yield* git(repo, [
415
448
  "commit",
416
449
  "--quiet",
@@ -422,6 +455,7 @@ export const createRule = Effect.fn("createRule")(
422
455
  yield* outro(
423
456
  [
424
457
  `Pushed ${branch} to ${shared}.`,
458
+ trialStatus(outcome),
425
459
  ...needs,
426
460
  `Open the pull request: https://github.com/${shared}/compare/${branch}?expand=1`,
427
461
  ].join("\n"),
package/src/init.ts CHANGED
@@ -2,6 +2,17 @@ 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 {
6
+ SHARED_LINT_DIRECTORY,
7
+ SHARED_LINT_INDEX,
8
+ SHARED_LINT_RULES,
9
+ SHARED_LINT_RULES_JSON,
10
+ SHARED_LINT_TYPES,
11
+ SHARED_LINT_TYPES_SOURCE,
12
+ sharedPackageJson,
13
+ sharedPackageName,
14
+ } from "#oxlint/shared.ts";
15
+ import { README_END, README_START, refreshReadme } from "#readme.ts";
5
16
  import { Effect, Schema } from "effect";
6
17
 
7
18
  export interface InitResult {
@@ -87,6 +98,16 @@ export const renderInvoice = (invoice: Invoice) => Html.table(invoice.lines);
87
98
  \`\`\`
88
99
  `;
89
100
 
101
+ const INSTRUCTIONS = `# Working with adhere
102
+
103
+ - Run \`adhere lint\` before you commit, and fix what it reports.
104
+ - A rule belongs in \`.adhere/rules/\`: one sentence saying what code must be,
105
+ with code that shows it.
106
+ - Run \`adhere validate\` after changing a rule. When a finding is wrong for
107
+ this code alone, say why in a suppression comment instead of weakening the
108
+ rule.
109
+ `;
110
+
90
111
  const NAME_EFFECTS = `---
91
112
  description: A function must be named after the domain action it performs, never after a generic verb like handle.
92
113
  threshold: 0.8
@@ -116,35 +137,135 @@ export const handle = (id: string) =>
116
137
 
117
138
  const sharedReadme = (org: string, repo: string) => `# ${repo}
118
139
 
119
- Shared [adhere](https://github.com/darkmatter/adhere) rules for ${org}'s
120
- repositories. Each rule is a directory in \`.adhere/rules/\` that holds a
121
- \`RULE.md\`, or a \`RULE.ts\`, and a repo that copies it in has Jev judge its
122
- files against it when it runs \`adhere lint\`.
140
+ Shared [adhere](https://github.com/darkmatter/adhere) rules and lint settings
141
+ for ${org}'s repositories. \`.adhere/rules/\` holds the rules Jev judges files
142
+ against, \`.adhere/instructions/\` holds instruction files for \`AGENTS.md\`, and
143
+ \`oxlint/\` is a plain ESM Oxlint config package for rules a linter can check
144
+ exactly.
123
145
 
124
146
  ## Use these rules
125
147
 
126
- List the rules, then copy them into a repo, all of them or one topic or rule:
148
+ List the rules, then copy them into a repo: every one, one topic, or one rule.
127
149
 
128
150
  \`\`\`sh
129
151
  adhere list ${org}/${repo}
130
- adhere install ${org}/${repo}
152
+ adhere install ${org}/${repo} # choose at a terminal
153
+ adhere install ${org}/${repo} --all # everything, without a prompt
131
154
  adhere install ${org}/${repo}/style
132
155
  adhere install ${org}/${repo}/style/prefer-small-files
133
156
  \`\`\`
134
157
 
135
- The copies land in \`.adhere/rules/${org}/${repo}/\` and are that repo's own from then
136
- on, to edit or to start other rules from. To take a later version of a rule,
137
- install it again with \`--force\`, which overwrites local edits.
158
+ At a terminal, a bare install lists what it can copy: instruction files
159
+ first, then rules grouped by topic. \`--all\` copies every instruction file and
160
+ rule the source names without asking and needs no terminal. Without either, a
161
+ run without a terminal fails and names the matches. A topic is the same list
162
+ filtered to that topic, and an exact rule's path copies just that rule;
163
+ neither copies instructions.
164
+
165
+ Rules land in \`.adhere/rules/${org}/${repo}/\` and instructions in
166
+ \`.adhere/instructions/${org}/${repo}/\`, and are that repo's own from then on,
167
+ to edit or to start other rules from. To take a later version, install again
168
+ with \`--force\`, which overwrites local edits; deselecting a file never
169
+ deletes it.
170
+
171
+ ## Use its lint settings
172
+
173
+ The lint package is not published. Pin this repo as a git dependency, at a
174
+ tag: tags are repo-wide, covering the rules, the instructions, and \`oxlint/\`
175
+ together.
176
+
177
+ \`\`\`sh
178
+ bun add -D "github:${org}/${repo}#v1.0.0"
179
+ \`\`\`
180
+
181
+ Then spread it into Vite+'s config, or plain Oxlint's:
182
+
183
+ \`\`\`ts
184
+ import sharedLint from "${sharedPackageName(org, repo)}/oxlint";
185
+ import { defineConfig } from "vite-plus";
186
+
187
+ export default defineConfig({
188
+ lint: {
189
+ ...sharedLint,
190
+ jsPlugins: [...sharedLint.jsPlugins, "./.adhere/oxlint/local.mjs"],
191
+ rules: {
192
+ ...sharedLint.rules,
193
+ "local/my-rule": "error",
194
+ },
195
+ },
196
+ });
197
+ \`\`\`
198
+
199
+ \`\`\`ts
200
+ // oxlint.config.ts, with plain Oxlint: oxlint -c oxlint.config.ts
201
+ import sharedLint from "${sharedPackageName(org, repo)}/oxlint";
202
+
203
+ export default { ...sharedLint };
204
+ \`\`\`
205
+
206
+ \`oxlint/index.js\` reads \`oxlint/rules.json\` and resolves the shared package's
207
+ plugin paths, so spreading its export works from any directory. Paths you add
208
+ stay relative to your config. Your project's own JS plugins live in
209
+ \`.adhere/oxlint/\` as \`.mjs\` files.
210
+
211
+ ## Rules and lint settings
212
+
213
+ The guide below is generated from \`.adhere/rules/\` and \`oxlint/rules.json\` by
214
+ \`adhere rules readme\`. Run it after adding, renaming, or deleting a rule, or
215
+ after changing a lint setting; it replaces only the block between its markers.
216
+ Rules are grouped by topic, and each entry opens its own statement, examples,
217
+ and explanation, with a link to the file it came from.
218
+
219
+ ${README_START}
220
+ ${README_END}
138
221
 
139
222
  ## Add a rule
140
223
 
141
224
  Add a directory under \`.adhere/rules/<topic>/\` with the rule in a \`RULE.md\`;
142
225
  its path is the rule's id. See
143
- [Rules as Markdown files](https://github.com/darkmatter/adhere#rules-as-markdown-files)
144
- and the [rule writing tips](https://github.com/darkmatter/adhere#rule-writing-tips).
226
+ [Rules as Markdown files](https://dm.sh/adhere/rule-format/#rules-as-markdown-files)
227
+ and the [rule writing tips](https://dm.sh/adhere/writing-good-rules/#rule-writing-tips).
145
228
  \`adhere validate\` checks each rule's wording and asks Jev whether any two
146
229
  contradict. CI runs it on every push to main and every pull request.
147
230
 
231
+ ## Add a lint setting or plugin
232
+
233
+ \`oxlint/rules.json\` starts \`{}\` and takes \`rules\`, \`jsPlugins\`, \`plugins\`, and
234
+ \`options\`. \`options\` is Oxlint's own options object; Vite+ uses the same
235
+ \`lint.options\` shape, and type-aware rules need \`oxlint-tsgolint\`, which Vite+
236
+ includes. Turn on a built-in rule by naming it:
237
+
238
+ \`\`\`json
239
+ { "rules": { "typescript/no-explicit-any": "error" } }
240
+ \`\`\`
241
+
242
+ A rule from a built-in plugin that Oxlint leaves off by default (\`react\`,
243
+ \`import\`, \`jsdoc\`, \`jest\`, \`vitest\`, \`jsx-a11y\`, \`nextjs\`, \`react-perf\`,
244
+ \`promise\`, \`node\`, \`vue\`) also needs that plugin in \`plugins\`. The list
245
+ replaces Oxlint's default set (\`eslint\`, \`typescript\`, \`unicorn\`, \`oxc\`), so
246
+ name every plugin you still want:
247
+
248
+ \`\`\`json
249
+ {
250
+ "plugins": ["eslint", "typescript", "unicorn", "oxc", "react"],
251
+ "rules": { "react/jsx-no-target-blank": "error" }
252
+ }
253
+ \`\`\`
254
+
255
+ A custom plugin is a plain ESM file in \`oxlint/plugins/\`; register it by a path
256
+ relative to \`oxlint/\`, and enable one of its rules by the plugin's name:
257
+
258
+ \`\`\`json
259
+ {
260
+ "jsPlugins": ["./plugins/${repo}.js"],
261
+ "rules": { "${repo}/no-foo": "error" }
262
+ }
263
+ \`\`\`
264
+
265
+ Keep the keys you are not changing. A project writes its own \`.mjs\` plugins to
266
+ \`.adhere/oxlint/\` and registers them in its \`vite.config.ts\` or
267
+ \`oxlint.config.ts\` instead; see the lint settings above.
268
+
148
269
  ## Setup
149
270
 
150
271
  \`alchemy.run.ts\` creates this repository on GitHub, or adopts it when it
@@ -159,6 +280,17 @@ git remote add origin git@github.com:${org}/${repo}.git && git push -u origin ma
159
280
  \`\`\`
160
281
 
161
282
  Deploying again updates the secret, as after rotating the key.
283
+
284
+ ## Tag a release
285
+
286
+ Tag the whole repo after committing rules, instructions, or lint changes. One
287
+ tag covers all of them, and consumers pin that tag; there are no per-rule
288
+ tags.
289
+
290
+ \`\`\`sh
291
+ git tag v1.0.0
292
+ git push origin v1.0.0
293
+ \`\`\`
162
294
  `;
163
295
 
164
296
  const SHARED_WORKFLOW = `name: adhere
@@ -214,29 +346,6 @@ export default Alchemy.Stack(
214
346
  );
215
347
  `;
216
348
 
217
- /**
218
- * alchemy's CLI runs on effect and its Node platform. They are pinned, as is
219
- * the platform's own dependency, since a range of prereleases takes the newest:
220
- * effect 4.0.0-rc.118 moved modules alchemy 2.0.0-beta.79 imports (checked
221
- * 2026-09-28).
222
- */
223
- const sharedPackage = (repo: string) =>
224
- `${JSON.stringify(
225
- {
226
- name: repo,
227
- private: true,
228
- type: "module",
229
- devDependencies: {
230
- "@effect/platform-node": "4.0.0-rc.117",
231
- alchemy: "2.0.0-beta.79",
232
- effect: "4.0.0-rc.117",
233
- },
234
- overrides: { "@effect/platform-node-shared": "4.0.0-rc.117" },
235
- },
236
- null,
237
- 2,
238
- )}\n`;
239
-
240
349
  const SHARED_GITIGNORE = `node_modules/
241
350
  .alchemy/
242
351
  `;
@@ -246,30 +355,38 @@ const CONFIG_PATH = CONFIG_FILES[0];
246
355
  /** The project of the config at `.adhere/config.ts`, and of the rules written in TypeScript beside it. */
247
356
  const CONFIG_PROJECT = ".adhere/tsconfig.json";
248
357
 
249
- const EXAMPLES = [
358
+ const RULE_EXAMPLES = [
250
359
  [".adhere/rules/style/prefer-small-files/RULE.md", SMALL_FILES],
251
360
  [".adhere/rules/style/name-domain-actions/RULE.md", NAME_EFFECTS],
252
361
  ] as const;
253
362
 
363
+ const SHARED_EXAMPLES = [
364
+ [".adhere/instructions/00-workflow.md", INSTRUCTIONS],
365
+ ...RULE_EXAMPLES,
366
+ ] as const;
367
+
254
368
  /**
255
- * A shared repo has no config: \`adhere install\` copies only rules, and
256
- * \`validate\` reads \`.adhere/rules/\` without one.
369
+ * A shared repo has no config: \`adhere install\` copies its rules and
370
+ * instructions, and \`validate\` reads \`.adhere/rules/\` without one.
257
371
  */
258
372
  const filesFor = (
259
373
  shared: string | undefined,
260
374
  visibility: Visibility,
261
375
  ): ReadonlyArray<readonly [string, string]> => {
262
376
  if (shared === undefined) {
263
- return [[CONFIG_PATH, CONFIG], [CONFIG_PROJECT, TSCONFIG], ...EXAMPLES];
377
+ return [[CONFIG_PATH, CONFIG], [CONFIG_PROJECT, TSCONFIG], ...RULE_EXAMPLES];
264
378
  }
265
379
  const [org = "", repo = ""] = shared.split("/");
266
380
  return [
267
381
  ["README.md", sharedReadme(org, repo)],
268
382
  [".github/workflows/adhere.yaml", SHARED_WORKFLOW],
269
383
  ["alchemy.run.ts", sharedStack(org, repo, visibility)],
270
- ["package.json", sharedPackage(repo)],
384
+ ["package.json", sharedPackageJson(org, repo)],
385
+ [`${SHARED_LINT_DIRECTORY}/index.js`, SHARED_LINT_INDEX],
386
+ [SHARED_LINT_TYPES, SHARED_LINT_TYPES_SOURCE],
387
+ [SHARED_LINT_RULES, SHARED_LINT_RULES_JSON],
271
388
  [".gitignore", SHARED_GITIGNORE],
272
- ...EXAMPLES,
389
+ ...SHARED_EXAMPLES,
273
390
  ];
274
391
  };
275
392
 
@@ -406,6 +523,15 @@ export const initProject = (
406
523
  }
407
524
  // A config at another path is in the project's own tsconfig, or not ours to place.
408
525
  if (path === CONFIG_PROJECT && existing !== undefined) continue;
526
+ // A shared README holds the author's prose; refresh its managed block instead.
527
+ if (
528
+ options.shared !== undefined &&
529
+ path === "README.md" &&
530
+ (yield* Effect.promise(() => exists(join(root, path))))
531
+ ) {
532
+ skipped.push(relative(root, join(root, path)));
533
+ continue;
534
+ }
409
535
  const status = yield* writeScaffoldFile(root, path, contents, options);
410
536
  if (status === "created") {
411
537
  created.push(relative(root, join(root, path)));
@@ -413,6 +539,9 @@ export const initProject = (
413
539
  skipped.push(relative(root, join(root, path)));
414
540
  }
415
541
  }
542
+ if (options.shared !== undefined) {
543
+ yield* refreshReadme(root).pipe(Effect.mapError((cause) => new Error(cause.message)));
544
+ }
416
545
  const install = yield* installPackages(root, options.shared !== undefined);
417
546
  return { created, skipped, install };
418
547
  });