@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.
- package/README.md +28 -1552
- package/package.json +10 -7
- package/presets/README.md +9 -1
- package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
- package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
- package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
- package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
- package/skills/adhere/SKILL.md +194 -30
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +49 -16
- package/src/cli.ts +216 -25
- package/src/config.ts +27 -4
- package/src/create/ask.ts +225 -9
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +70 -19
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +562 -0
- package/src/create/trial.ts +611 -0
- package/src/create/wizard.ts +128 -94
- package/src/init.ts +169 -40
- package/src/install.ts +295 -93
- package/src/instructions.ts +34 -12
- package/src/markdown.ts +12 -2
- package/src/oxlint/catalog.json +7844 -0
- package/src/oxlint/catalog.ts +72 -0
- package/src/oxlint/shared.ts +140 -0
- package/src/readme-guide.ts +431 -0
- package/src/readme.ts +291 -0
- package/src/services/AdhereConfig.ts +36 -3
- package/src/services/AuditCache.ts +176 -169
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +3 -3
- package/src/workflows/audit.ts +5 -2
- package/src/workflows/format.ts +1 -1
package/src/create/wizard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) =>
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 (
|
|
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
|
-
|
|
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*
|
|
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:
|
|
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*
|
|
378
|
-
|
|
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
|
-
|
|
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, `${
|
|
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)}.\
|
|
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
|
-
|
|
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
|
|
120
|
-
repositories.
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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://
|
|
144
|
-
and the [rule writing tips](https://
|
|
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
|
|
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
|
|
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], ...
|
|
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",
|
|
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
|
-
...
|
|
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
|
});
|