@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.
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
  });