@drkmttr/adhere 0.18.1 → 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 +62 -1575
- package/package.json +18 -15
- package/presets/README.md +140 -0
- 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 -29
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +60 -26
- package/src/cli.ts +334 -36
- package/src/config.ts +173 -142
- package/src/create/agents.ts +149 -0
- package/src/create/ask.ts +363 -0
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +191 -0
- 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 +466 -0
- package/src/index.ts +2 -0
- package/src/init.ts +171 -41
- package/src/install.ts +296 -94
- package/src/instructions.ts +98 -0
- package/src/markdown.ts +15 -11
- 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/rules.ts +42 -45
- package/src/services/AdhereConfig.ts +67 -21
- package/src/services/AuditCache.ts +176 -169
- package/src/services/Credentials.ts +14 -8
- package/src/services/Jev.reads.test.ts +22 -0
- package/src/services/Jev.ts +35 -13
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +4 -4
- package/src/workflows/audit.ts +85 -14
- package/src/workflows/format.ts +98 -1
- package/src/workspaces.test.ts +37 -1
- package/src/workspaces.ts +121 -12
|
@@ -25,10 +25,10 @@ the `packageManager` field in `package.json` names it; with neither, **Ask**.
|
|
|
25
25
|
|
|
26
26
|
| Manager | Add adhere | Run adhere |
|
|
27
27
|
| ------- | -------------------------------- | ------------------ |
|
|
28
|
-
| bun | `bun add -D @drkmttr/adhere` | `
|
|
28
|
+
| bun | `bun add -D @drkmttr/adhere` | `bun run adhere` |
|
|
29
29
|
| pnpm | `pnpm add -D @drkmttr/adhere` | `pnpm exec adhere` |
|
|
30
30
|
| yarn | `yarn add -D @drkmttr/adhere` | `yarn adhere` |
|
|
31
|
-
| npm | `npm install -D @drkmttr/adhere` | `
|
|
31
|
+
| npm | `npm install -D @drkmttr/adhere` | `npm exec adhere` |
|
|
32
32
|
|
|
33
33
|
In a repo with a `package.json`, add adhere as a dev dependency with its
|
|
34
34
|
manager, so the repo pins the version the team and CI run. From then on,
|
|
@@ -37,12 +37,14 @@ wherever this skill says `adhere`, run it the manager's way, as in
|
|
|
37
37
|
|
|
38
38
|
With pnpm, at a workspace root, add `--ignore-workspace-root-check`.
|
|
39
39
|
|
|
40
|
-
Default lint reads code and `workspacePackages` without starting a compiler
|
|
40
|
+
Default lint reads code and `workspacePackages` without starting a compiler,
|
|
41
|
+
and leaves out bundled preset rules that declare `symbols` or `references`.
|
|
41
42
|
`typeAware` defaults to `false`; `typeAware: true` or `--type-aware` opts into
|
|
42
|
-
both `symbols` and `references`, regardless of `reads
|
|
43
|
-
workspace context only, defaulting to
|
|
44
|
-
reader entries alone do not opt in.
|
|
45
|
-
awareness off. Explain the extra
|
|
43
|
+
both `symbols` and `references`, regardless of `reads`, and includes those
|
|
44
|
+
rules. `reads` now controls workspace context only, defaulting to
|
|
45
|
+
`["workspacePackages"]`; legacy native reader entries alone do not opt in.
|
|
46
|
+
Code alone uses `reads: []` with type awareness off. Explain the extra
|
|
47
|
+
context/cost and **Ask** before opting in.
|
|
46
48
|
|
|
47
49
|
With type awareness enabled, native TypeScript starts only during execution,
|
|
48
50
|
after confirmation, when pending judgments or cached findings needing location
|
|
@@ -56,18 +58,19 @@ and validate do not start the compiler.
|
|
|
56
58
|
In a repo without a `package.json`, inspect any `adhere` on PATH with
|
|
57
59
|
`adhere --version`, then **Ask** whether to use the npm/Bun package launcher
|
|
58
60
|
or a raw executable for the same default compiler-free audit. The raw release
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
needs no Node or package manager and runs with workspace context without
|
|
62
|
+
special configuration. It cannot run a type-aware audit: use the package
|
|
63
|
+
launcher or a checkout if the user opts in. Do not silently turn off an
|
|
64
|
+
explicitly chosen `typeAware` setting. Only fetch the raw release after the
|
|
65
|
+
user chooses that alternative. On macOS and Linux:
|
|
64
66
|
|
|
65
67
|
```sh
|
|
66
|
-
curl -fsSL
|
|
67
|
-
https://github.com/darkmatter/adhere/releases/latest/download/adhere-$(uname -s)-$(uname -m)
|
|
68
|
-
chmod +x ~/.local/bin/adhere
|
|
68
|
+
curl -fsSL https://github.com/darkmatter/adhere/raw/main/scripts/install.sh | sh
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
On Windows, download `adhere-Windows-x86_64.exe` from
|
|
72
|
+
https://github.com/darkmatter/adhere/releases/latest.
|
|
73
|
+
|
|
71
74
|
Validate and lint's paid requests need a TypeSafe AI API key: `TYPESAFE_API_KEY`,
|
|
72
75
|
or one saved by `adhere login`, which the user runs; never ask for the key in
|
|
73
76
|
chat or print it. Compiler-free planning with `--limit 0` needs no key.
|
|
@@ -90,7 +93,10 @@ from and the evidence.
|
|
|
90
93
|
|
|
91
94
|
Keep only conventions that need judgment: about intent, shape, or placement.
|
|
92
95
|
Drop any a regex, an import check, or the type checker could flag every time,
|
|
93
|
-
and note where it belongs instead
|
|
96
|
+
and note where it belongs instead: a native Oxlint rule, or a convention a
|
|
97
|
+
small syntax-only Oxlint JS plugin could decide. `adhere rules create` routes
|
|
98
|
+
those two, and leaves the rest to adhere. Drop any a preset below already
|
|
99
|
+
covers.
|
|
94
100
|
|
|
95
101
|
**Presets.** Match the stack: `typescript` for TypeScript, `react` for React,
|
|
96
102
|
`security` for code that handles secrets, SQL, shell commands, or paths,
|
|
@@ -99,9 +105,10 @@ and note where it belongs instead. Drop any a preset below already covers.
|
|
|
99
105
|
https://github.com/darkmatter/adhere/tree/main/presets, to describe them and
|
|
100
106
|
to avoid drafting duplicates.
|
|
101
107
|
|
|
102
|
-
**A shared repo.** **Ask** whether the organization keeps adhere
|
|
103
|
-
repo. If it does, `adhere list org/repo` prints
|
|
104
|
-
descriptions
|
|
108
|
+
**A shared repo.** **Ask** whether the organization keeps adhere instructions,
|
|
109
|
+
rules, or lint settings in a repo. If it does, `adhere list org/repo` prints
|
|
110
|
+
its rules with their descriptions; its instruction files and `oxlint/` package
|
|
111
|
+
are separate, and `adhere install` offers the instruction files too.
|
|
105
112
|
|
|
106
113
|
## 3. Draft the repo's rules
|
|
107
114
|
|
|
@@ -130,7 +137,8 @@ Presets
|
|
|
130
137
|
From acme/standards
|
|
131
138
|
5. data/brand-ports A port must be a branded, range-checked integer, never a bare number.
|
|
132
139
|
Dropped as a linter's job
|
|
133
|
-
- "no default exports":
|
|
140
|
+
- "no default exports": native oxlint import/no-default-export
|
|
141
|
+
- "no local date formatting": custom syntax-only oxlint plugin
|
|
134
142
|
```
|
|
135
143
|
|
|
136
144
|
For a preset, offer the whole preset or its topics. Show a draft's full text
|
|
@@ -151,8 +159,30 @@ the wording of any. Revise drafts the user wants changed and show them again.
|
|
|
151
159
|
workspace context, not native maps. Only replace `reads` if the user chooses
|
|
152
160
|
to limit workspace context. If requested type awareness lacks compiler resources,
|
|
153
161
|
use the package launcher or a checkout; do not silently turn it off.
|
|
154
|
-
-
|
|
155
|
-
|
|
162
|
+
- Copy chosen shared instructions and rules: run `adhere install org/repo` and
|
|
163
|
+
let the user pick at the terminal, or add `--all` only when they chose
|
|
164
|
+
everything (the non-interactive path). Name an exact rule to copy it without
|
|
165
|
+
asking. Instruction files land in `.adhere/instructions/org/repo/` and rules
|
|
166
|
+
in `.adhere/rules/org/repo/`; `adhere postinstall` renders the instructions,
|
|
167
|
+
copied files first.
|
|
168
|
+
- **Ask** before adding the shared repo's lint package: pin it as a git
|
|
169
|
+
dependency at the repo-wide tag, then spread `@org/repo/oxlint` into the
|
|
170
|
+
project's Vite+ lint block or `oxlint.config.ts`. It is a package, not an
|
|
171
|
+
`adhere install` copy. Project plugins live in `.adhere/oxlint/`.
|
|
172
|
+
- For a chosen linter candidate, run `adhere rules create "<convention>"` and
|
|
173
|
+
follow its route. A native Oxlint rule must be one of adhere's catalog ids;
|
|
174
|
+
an unknown id is rejected. It must carry the rule's exact option values when
|
|
175
|
+
it takes options; adhere prints `["error", ...options]`, never a bare
|
|
176
|
+
`"error"` that would enforce nothing. It prints the config for the user's lint
|
|
177
|
+
config, and adhere does not edit it. A custom syntax-only plugin is shown with
|
|
178
|
+
valid and invalid examples and runs nothing until approved, then its tests
|
|
179
|
+
use the project's Oxlint through `oxlint/plugins-dev` or
|
|
180
|
+
`vite-plus/lint/plugins-dev`. That needs its own Oxlint or Vite+ and Node 22
|
|
181
|
+
or newer on `PATH`; Oxlint's RuleTester refuses Bun and older Node, and
|
|
182
|
+
without it nothing is written and no repair is attempted. One failed run goes
|
|
183
|
+
back to the agent for repair; the user approves the repaired plugin, and it
|
|
184
|
+
runs again. Project files go to `.adhere/oxlint/` as `.mjs`; shared files go
|
|
185
|
+
to `oxlint/plugins/` as `.js` on a branch for a pull request.
|
|
156
186
|
- Add `exclude` globs for generated or vendored code the default skips miss.
|
|
157
187
|
|
|
158
188
|
## 6. Validate
|
|
@@ -163,8 +193,10 @@ adhere validate
|
|
|
163
193
|
|
|
164
194
|
It reports rules worded against the tips, rules a linter could check, and
|
|
165
195
|
contradictions between rules that apply to the same files. Fix the wording
|
|
166
|
-
yourself. For a linter-checkable rule
|
|
167
|
-
|
|
196
|
+
yourself. For a linter-checkable rule, **Ask** whether to move it to a native
|
|
197
|
+
Oxlint rule or an approved syntax-only plugin, or keep it as an adhere rule.
|
|
198
|
+
For a contradiction, **Ask** which rule to keep and apply the change. Run it
|
|
199
|
+
again until it reports no contradictions.
|
|
168
200
|
|
|
169
201
|
## 7. The first lint
|
|
170
202
|
|
|
@@ -401,9 +433,11 @@ This needs the cache committed on the base branch first.
|
|
|
401
433
|
|
|
402
434
|
Tell the user:
|
|
403
435
|
|
|
404
|
-
- the rules imported, by source, and where they are
|
|
436
|
+
- the rules imported, by source, and where they are, plus any shared
|
|
437
|
+
instruction files copied and the `oxlint/` package pinned;
|
|
405
438
|
- the presets and shared repo named in the config or installed;
|
|
406
|
-
- the candidates
|
|
439
|
+
- the candidates routed to native Oxlint, an approved plugin, or an adhere
|
|
440
|
+
rule, and which config registers each;
|
|
407
441
|
- the last lint's findings, and what was fixed, made a warning, or suppressed;
|
|
408
442
|
- the CI workflow and whether the secret is set;
|
|
409
443
|
- what is left for them: pushing, setting the secret, fixing remaining findings.
|
package/src/cli.ts
CHANGED
|
@@ -24,21 +24,49 @@ import {
|
|
|
24
24
|
type Progress,
|
|
25
25
|
progressAfter,
|
|
26
26
|
progressLine,
|
|
27
|
+
renderJson,
|
|
28
|
+
renderMarkdown,
|
|
29
|
+
renderRuleReports,
|
|
27
30
|
sendQuestion,
|
|
28
31
|
} from "#workflows/format.ts";
|
|
29
32
|
import type { Flags } from "#config.ts";
|
|
30
33
|
import { findContradictions, formatContradictions } from "#contradictions.ts";
|
|
34
|
+
import { createRule } from "#create/wizard.ts";
|
|
35
|
+
import { AGENTS } from "#create/agents.ts";
|
|
31
36
|
import { type Install, initProject } from "#init.ts";
|
|
32
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
type CatalogMatch,
|
|
39
|
+
type InstallPicks,
|
|
40
|
+
type Source,
|
|
41
|
+
catalogOf,
|
|
42
|
+
cloned,
|
|
43
|
+
describeRules,
|
|
44
|
+
installSelected,
|
|
45
|
+
instructionPath,
|
|
46
|
+
listRules,
|
|
47
|
+
matchOf,
|
|
48
|
+
parseSource,
|
|
49
|
+
rulePath,
|
|
50
|
+
} from "#install.ts";
|
|
51
|
+
import { renderInstructions } from "#instructions.ts";
|
|
33
52
|
import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
|
|
34
53
|
import { type PresetName, presetNames } from "#presets.ts";
|
|
35
54
|
import type { SavedKey } from "#providers/provider.ts";
|
|
55
|
+
import {
|
|
56
|
+
ReadmeUnavailable,
|
|
57
|
+
standardsRepoOf,
|
|
58
|
+
isSharedRepo,
|
|
59
|
+
originUrlAt,
|
|
60
|
+
refreshRemoteReadme,
|
|
61
|
+
refreshSharedReadme,
|
|
62
|
+
} from "#readme.ts";
|
|
36
63
|
import { globalRuleSet } from "#rules.ts";
|
|
37
64
|
import { formatStrays, straysAmong } from "#wording.ts";
|
|
38
65
|
import {
|
|
39
66
|
Console,
|
|
40
67
|
Effect,
|
|
41
68
|
Exit,
|
|
69
|
+
FileSystem,
|
|
42
70
|
Layer,
|
|
43
71
|
LogLevel,
|
|
44
72
|
Option,
|
|
@@ -49,8 +77,9 @@ import {
|
|
|
49
77
|
Schema,
|
|
50
78
|
Stdio,
|
|
51
79
|
Stream,
|
|
80
|
+
Terminal,
|
|
52
81
|
} from "effect";
|
|
53
|
-
import { Argument, Command, Flag, Prompt } from "effect/cli";
|
|
82
|
+
import { Argument, CliError, Command, Flag, Prompt } from "effect/cli";
|
|
54
83
|
// A Bun text import (https://bun.sh/docs/bundler/loaders#text): the file's
|
|
55
84
|
// contents become a string at bundle time, so the compiled binary carries the
|
|
56
85
|
// skill without an --asset flag. `skills/` sits beside `src/`, outside the `#`
|
|
@@ -121,7 +150,7 @@ const sufficiencyThreshold = Flag.Finite("sufficiency-threshold").pipe(
|
|
|
121
150
|
const typeAware = Flag.Boolean("type-aware").pipe(
|
|
122
151
|
Flag.optional,
|
|
123
152
|
Flag.withDescription(
|
|
124
|
-
"Include compiler-resolved symbols and declaration excerpts. Off by default.",
|
|
153
|
+
"Include compiler-resolved symbols and declaration excerpts, and the bundled rules that declare them. Off by default.",
|
|
125
154
|
),
|
|
126
155
|
);
|
|
127
156
|
|
|
@@ -246,9 +275,43 @@ export const auditLayer = (flags: Flags, filter: ReadonlyArray<string> = []) =>
|
|
|
246
275
|
/** `adhere lint`: the audit. */
|
|
247
276
|
export const lintCommand = Command.make(
|
|
248
277
|
"lint",
|
|
249
|
-
{
|
|
278
|
+
{
|
|
279
|
+
preset,
|
|
280
|
+
threshold,
|
|
281
|
+
sufficiencyThreshold,
|
|
282
|
+
typeAware,
|
|
283
|
+
yes,
|
|
284
|
+
limit,
|
|
285
|
+
rpm,
|
|
286
|
+
filter,
|
|
287
|
+
denyWarnings,
|
|
288
|
+
json: Flag.Boolean("json").pipe(
|
|
289
|
+
Flag.withDefault(false),
|
|
290
|
+
Flag.withDescription(
|
|
291
|
+
"Write one JSON file per violated rule to .adhere/reports/. Use --stdout for one combined report instead.",
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
md: Flag.Boolean("md").pipe(
|
|
295
|
+
Flag.withDefault(false),
|
|
296
|
+
Flag.withDescription(
|
|
297
|
+
"Write one Markdown file per violated rule to .adhere/reports/. Use --stdout for one combined report instead.",
|
|
298
|
+
),
|
|
299
|
+
),
|
|
300
|
+
stdout: Flag.Boolean("stdout").pipe(
|
|
301
|
+
Flag.withDefault(false),
|
|
302
|
+
Flag.withDescription(
|
|
303
|
+
"Print all findings together instead of writing files. Select either --json or --md for a formatted report.",
|
|
304
|
+
),
|
|
305
|
+
),
|
|
306
|
+
},
|
|
250
307
|
(input) =>
|
|
251
308
|
Effect.gen(function* () {
|
|
309
|
+
if (input.stdout && input.json && input.md) {
|
|
310
|
+
return yield* CliError.UserError.make({
|
|
311
|
+
cause:
|
|
312
|
+
"Choose either --json or --md with --stdout; both formats can be written to files together.",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
252
315
|
const stdio = yield* Stdio.Stdio;
|
|
253
316
|
const plan = yield* planAudit({ limit: Option.getOrUndefined(input.limit) });
|
|
254
317
|
const limits = { filter: input.filter, rpm: Option.getOrUndefined(input.rpm) };
|
|
@@ -258,8 +321,7 @@ export const lintCommand = Command.make(
|
|
|
258
321
|
if (input.filter.length === 0) yield* pruneCache(plan);
|
|
259
322
|
return;
|
|
260
323
|
}
|
|
261
|
-
//
|
|
262
|
-
// the report is redirected, or in CI.
|
|
324
|
+
// Keep prompting limited to interactive runs, including when JSON is requested.
|
|
263
325
|
const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
|
|
264
326
|
if (
|
|
265
327
|
(plan.requests > 0 || (plan.cachedLocateRequests ?? 0) > 0) &&
|
|
@@ -268,6 +330,11 @@ export const lintCommand = Command.make(
|
|
|
268
330
|
) {
|
|
269
331
|
const send = yield* Prompt.run(
|
|
270
332
|
Prompt.Confirm({ message: sendQuestion(plan), initial: true }),
|
|
333
|
+
).pipe(
|
|
334
|
+
Effect.updateService(Terminal.Terminal, (terminal) => ({
|
|
335
|
+
...terminal,
|
|
336
|
+
display: toStderr,
|
|
337
|
+
})),
|
|
271
338
|
);
|
|
272
339
|
if (!send) {
|
|
273
340
|
yield* toStderr("Nothing sent.\n");
|
|
@@ -283,16 +350,33 @@ export const lintCommand = Command.make(
|
|
|
283
350
|
if (input.filter.length === 0) yield* pruneCache(plan);
|
|
284
351
|
const color = yield* stdio.stdoutIsTerminal;
|
|
285
352
|
const path = yield* Path.Path;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
353
|
+
const options = {
|
|
354
|
+
color,
|
|
355
|
+
root: path.resolve(),
|
|
356
|
+
threshold: plan.threshold,
|
|
357
|
+
sufficiencyThreshold: plan.sufficiencyThreshold,
|
|
358
|
+
...(plan.provider === undefined ? {} : { provider: plan.provider }),
|
|
359
|
+
};
|
|
360
|
+
if (!input.stdout && (input.json || input.md)) {
|
|
361
|
+
const fs = yield* FileSystem.FileSystem;
|
|
362
|
+
const directory = path.join(options.root, ".adhere", "reports");
|
|
363
|
+
const reports = renderRuleReports(result, input, options);
|
|
364
|
+
yield* fs.remove(directory, { recursive: true, force: true });
|
|
365
|
+
yield* fs.makeDirectory(directory, { recursive: true });
|
|
366
|
+
for (const [name, report] of reports) {
|
|
367
|
+
yield* fs.writeFileString(path.join(directory, name), report);
|
|
368
|
+
}
|
|
369
|
+
yield* toStderr(`Wrote ${reports.size} report files to .adhere/reports/.\n`);
|
|
370
|
+
} else {
|
|
371
|
+
// One write: separate Console.log calls have interleaved out of order here.
|
|
372
|
+
yield* Console.log(
|
|
373
|
+
input.json
|
|
374
|
+
? renderJson(result, options)
|
|
375
|
+
: input.md
|
|
376
|
+
? renderMarkdown(result, options)
|
|
377
|
+
: render(result, options).join("\n"),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
296
380
|
// Only errors fail the run, unless --deny-warnings: a warning is for a nit or a noisy rule.
|
|
297
381
|
const failed = failing(result.findings, input.denyWarnings);
|
|
298
382
|
if (failed.length > 0) {
|
|
@@ -326,7 +410,7 @@ export const validateLayer = (flags: Flags) =>
|
|
|
326
410
|
|
|
327
411
|
/**
|
|
328
412
|
* `adhere validate`: everything `lint` loads, then how each rule's wording
|
|
329
|
-
* differs from the
|
|
413
|
+
* differs from the docs' rule writing tips, then the linter check's tallies
|
|
330
414
|
* from lint, then Jev on whether any two rules that apply to the same files
|
|
331
415
|
* contradict. Only a contradiction fails the run: the tips and the linter
|
|
332
416
|
* check are advice. A config, rule file, or preset that does not decode
|
|
@@ -356,7 +440,7 @@ export const validateCommand = Command.make("validate", { preset }, () =>
|
|
|
356
440
|
}),
|
|
357
441
|
).pipe(
|
|
358
442
|
Command.withDescription(
|
|
359
|
-
"Load the config, rule files, and presets the way lint does, check each rule's wording against the
|
|
443
|
+
"Load the config, rule files, and presets the way lint does, check each rule's wording against the docs' rule writing tips, report the rules lint's linter check flags, then ask Jev whether any two rules that apply to the same files contradict each other.",
|
|
360
444
|
),
|
|
361
445
|
Command.provide((input) => validateLayer({ presets: input.preset })),
|
|
362
446
|
);
|
|
@@ -379,7 +463,7 @@ const shared = Flag.String("shared").pipe(
|
|
|
379
463
|
),
|
|
380
464
|
Flag.optional,
|
|
381
465
|
Flag.withDescription(
|
|
382
|
-
"Scaffold a repo of rules
|
|
466
|
+
"Scaffold a repo of instructions, rules, and a lint package other repos use, named org/repo on GitHub: example instructions and rules, a README, a CI workflow that runs adhere validate, and an alchemy.run.ts that creates the repo, public unless you make it private when asked, and its TYPESAFE_API_KEY secret.",
|
|
383
467
|
),
|
|
384
468
|
);
|
|
385
469
|
|
|
@@ -408,7 +492,7 @@ export const initCommand = Command.make("init", { force, shared }, (input) =>
|
|
|
408
492
|
}),
|
|
409
493
|
).pipe(
|
|
410
494
|
Command.withDescription(
|
|
411
|
-
"Scaffold .adhere/config.ts and example Markdown rules, and add @drkmttr/adhere to devDependencies with the project's package manager.",
|
|
495
|
+
"Scaffold .adhere/config.ts, .adhere/tsconfig.json, and example Markdown rules, and add @drkmttr/adhere to devDependencies with the project's package manager.",
|
|
412
496
|
),
|
|
413
497
|
);
|
|
414
498
|
|
|
@@ -418,7 +502,7 @@ const source = Argument.String("source").pipe(
|
|
|
418
502
|
(spec) => `org/repo, then optionally a topic or rule and #ref, not ${spec}`,
|
|
419
503
|
),
|
|
420
504
|
Argument.withDescription(
|
|
421
|
-
"A GitHub repo with rules in .adhere/rules/, as org/repo, optionally followed by a topic or a rule, as in org/repo/data/brand-ports, and by #branch or #tag.",
|
|
505
|
+
"A GitHub repo with instructions in .adhere/instructions/ and rules in .adhere/rules/, as org/repo, optionally followed by a topic or a rule, as in org/repo/data/brand-ports, and by #branch or #tag.",
|
|
422
506
|
),
|
|
423
507
|
);
|
|
424
508
|
|
|
@@ -437,30 +521,136 @@ export const listCommand = Command.make("list", { source }, (input) =>
|
|
|
437
521
|
),
|
|
438
522
|
);
|
|
439
523
|
|
|
440
|
-
|
|
524
|
+
class InstallUnavailable extends Schema.TaggedError<InstallUnavailable>()("InstallUnavailable", {
|
|
525
|
+
message: Schema.String,
|
|
526
|
+
}) {}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* What `adhere install` copies: one exact rule without asking, everything
|
|
530
|
+
* when `--all` is given, or what the user picks at a terminal. Without a
|
|
531
|
+
* terminal, a choice refuses and says what `--all` would copy and which exact
|
|
532
|
+
* rule can be named instead. A choice already installed is preselected, so
|
|
533
|
+
* running install again offers to bring it up to date, and deselecting it
|
|
534
|
+
* leaves it as it is.
|
|
535
|
+
*/
|
|
536
|
+
const choosePicks = Effect.fn("install.choosePicks")(function* (
|
|
537
|
+
source: Source,
|
|
538
|
+
match: CatalogMatch,
|
|
539
|
+
options: { readonly all: boolean; readonly root: string; readonly clone: string },
|
|
540
|
+
) {
|
|
541
|
+
if (match.kind === "direct") {
|
|
542
|
+
return { instructions: [], rules: [match.rule.id] } satisfies InstallPicks;
|
|
543
|
+
}
|
|
544
|
+
if (options.all) {
|
|
545
|
+
return {
|
|
546
|
+
instructions: match.instructions,
|
|
547
|
+
rules: match.rules.map(({ id }) => id),
|
|
548
|
+
} satisfies InstallPicks;
|
|
549
|
+
}
|
|
550
|
+
const pinned = source.ref === undefined ? "" : `#${source.ref}`;
|
|
551
|
+
const stdio = yield* Stdio.Stdio;
|
|
552
|
+
if (!(yield* stdio.stdinIsTerminal) || !(yield* stdio.stdoutIsTerminal)) {
|
|
553
|
+
const [first] = match.rules;
|
|
554
|
+
const ids = [
|
|
555
|
+
...match.instructions.map((file) => `instructions/${file}`),
|
|
556
|
+
...match.rules.map(({ id }) => id),
|
|
557
|
+
];
|
|
558
|
+
const spec = `${source.repo}${source.rule === "" ? "" : `/${source.rule}`}${pinned}`;
|
|
559
|
+
const exact =
|
|
560
|
+
first === undefined
|
|
561
|
+
? ""
|
|
562
|
+
: `, or name one rule exactly, as in ${source.repo}/${first.id}${pinned}`;
|
|
563
|
+
return yield* new InstallUnavailable({
|
|
564
|
+
message: `adhere install ${spec}: no terminal to choose from. Pass --all to copy every match (${ids.join(", ")})${exact}.`,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
const fs = yield* FileSystem.FileSystem;
|
|
568
|
+
const path = yield* Path.Path;
|
|
569
|
+
type Pick =
|
|
570
|
+
| { readonly kind: "instruction"; readonly path: string }
|
|
571
|
+
| { readonly kind: "rule"; readonly id: string };
|
|
572
|
+
const instructionChoices = yield* Effect.forEach(match.instructions, (file) =>
|
|
573
|
+
fs.exists(path.join(options.root, instructionPath(source, file))).pipe(
|
|
574
|
+
Effect.orElseSucceed(() => false),
|
|
575
|
+
Effect.map((selected) => ({
|
|
576
|
+
title: `instructions/${file}`,
|
|
577
|
+
value: { kind: "instruction", path: file } as const,
|
|
578
|
+
selected,
|
|
579
|
+
})),
|
|
580
|
+
),
|
|
581
|
+
);
|
|
582
|
+
const described = yield* describeRules(source, options.clone, match.rules);
|
|
583
|
+
const ruleChoices = yield* Effect.forEach(described, (rule) =>
|
|
584
|
+
fs.exists(path.join(options.root, rulePath(source, rule.id))).pipe(
|
|
585
|
+
Effect.orElseSucceed(() => false),
|
|
586
|
+
Effect.map((selected) => ({
|
|
587
|
+
title: rule.id,
|
|
588
|
+
description: rule.description,
|
|
589
|
+
value: { kind: "rule", id: rule.id } as const,
|
|
590
|
+
selected,
|
|
591
|
+
})),
|
|
592
|
+
),
|
|
593
|
+
);
|
|
594
|
+
const picks: ReadonlyArray<Pick> = yield* Prompt.run(
|
|
595
|
+
Prompt.MultiSelect<Pick>({
|
|
596
|
+
message:
|
|
597
|
+
source.rule === ""
|
|
598
|
+
? `What should adhere copy from ${source.repo}${pinned}?`
|
|
599
|
+
: `Which rules under ${source.repo}/${source.rule}${pinned} should adhere copy?`,
|
|
600
|
+
choices: [...instructionChoices, ...ruleChoices],
|
|
601
|
+
}),
|
|
602
|
+
);
|
|
603
|
+
return {
|
|
604
|
+
instructions: picks.flatMap((pick) => (pick.kind === "instruction" ? [pick.path] : [])),
|
|
605
|
+
rules: picks.flatMap((pick) => (pick.kind === "rule" ? [pick.id] : [])),
|
|
606
|
+
} satisfies InstallPicks;
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
/** `adhere install org/repo`: another repo's instructions and rules, copied into this one's `.adhere/`. */
|
|
441
610
|
export const installCommand = Command.make(
|
|
442
611
|
"install",
|
|
443
612
|
{
|
|
444
613
|
source,
|
|
445
614
|
force: Flag.Boolean("force").pipe(
|
|
446
615
|
Flag.withDefault(false),
|
|
447
|
-
Flag.withDescription(
|
|
616
|
+
Flag.withDescription(
|
|
617
|
+
"Replace instruction files and rules already in .adhere/, file by file and directory by directory.",
|
|
618
|
+
),
|
|
619
|
+
),
|
|
620
|
+
all: Flag.Boolean("all").pipe(
|
|
621
|
+
Flag.withDefault(false),
|
|
622
|
+
Flag.withDescription(
|
|
623
|
+
"Copy every instruction file and rule the source names, without asking. Needs no terminal.",
|
|
624
|
+
),
|
|
448
625
|
),
|
|
449
626
|
},
|
|
450
627
|
(input) =>
|
|
451
|
-
Effect.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
628
|
+
Effect.scoped(
|
|
629
|
+
Effect.gen(function* () {
|
|
630
|
+
const path = yield* Path.Path;
|
|
631
|
+
const root = path.resolve();
|
|
632
|
+
const clone = yield* cloned(input.source);
|
|
633
|
+
const catalog = yield* catalogOf(input.source, clone);
|
|
634
|
+
const match = yield* matchOf(input.source, catalog);
|
|
635
|
+
const picks = yield* choosePicks(input.source, match, {
|
|
636
|
+
all: input.all,
|
|
637
|
+
root,
|
|
638
|
+
clone,
|
|
639
|
+
});
|
|
640
|
+
const result = yield* installSelected(root, input.source, clone, picks, {
|
|
641
|
+
force: input.force,
|
|
642
|
+
});
|
|
643
|
+
yield* Console.log(
|
|
644
|
+
[
|
|
645
|
+
result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
|
|
646
|
+
result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
|
|
647
|
+
].join("\n"),
|
|
648
|
+
);
|
|
649
|
+
}),
|
|
650
|
+
),
|
|
461
651
|
).pipe(
|
|
462
652
|
Command.withDescription(
|
|
463
|
-
"Copy
|
|
653
|
+
"Copy instruction files and rules from a GitHub repo's .adhere/ into this repo's .adhere/instructions/<org>/<repo>/ and .adhere/rules/<org>/<repo>/. At a terminal, choose which instruction files and rules to copy; name a topic for its rules, or name one rule exactly to copy it without asking. --all copies everything. Existing files are skipped unless --force.",
|
|
464
654
|
),
|
|
465
655
|
);
|
|
466
656
|
|
|
@@ -556,11 +746,117 @@ export const skillCommand = Command.make("skill", { name: skillName }, ({ name }
|
|
|
556
746
|
),
|
|
557
747
|
);
|
|
558
748
|
|
|
749
|
+
const agent = Flag.Literals("agent", AGENTS).pipe(
|
|
750
|
+
Flag.optional,
|
|
751
|
+
Flag.withDescription(
|
|
752
|
+
"The coding agent to ask this time: claude, codex, or omp, in place of the one setup saved.",
|
|
753
|
+
),
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
/** `adhere rules create [idea]`: a wizard that writes one rule, with a coding agent's help. */
|
|
757
|
+
const rulesCreateCommand = Command.make(
|
|
758
|
+
"create",
|
|
759
|
+
{
|
|
760
|
+
idea: Argument.String("idea").pipe(
|
|
761
|
+
Argument.optional,
|
|
762
|
+
Argument.withDescription("What the rule should enforce. Asked for when left out."),
|
|
763
|
+
),
|
|
764
|
+
agent,
|
|
765
|
+
setup: Flag.Boolean("setup").pipe(
|
|
766
|
+
Flag.withDefault(false),
|
|
767
|
+
Flag.withDescription(
|
|
768
|
+
"Choose again which agent to ask and which GitHub repo, as org/repo, holds the organization's shared rules. The first run asks without it.",
|
|
769
|
+
),
|
|
770
|
+
),
|
|
771
|
+
},
|
|
772
|
+
(input) => createRule(input.idea, input.agent, input.setup).pipe(Effect.provide(statusLayer)),
|
|
773
|
+
).pipe(
|
|
774
|
+
Command.withDescription(
|
|
775
|
+
"Write a rule with a coding agent's help: it suggests whether the convention is a native oxlint rule (an id from adhere's catalog; unknown ids are rejected, with its exact option values and the config to print or enable in the shared repo), a mechanical rule (an oxlint JS plugin, approved before it runs and tested on Node 22 or newer; a failed test gets one repair and reapproval), or one adhere must judge. It then suggests whether the rule belongs to this project or the organization's shared rules repo, names it, chooses a RULE.md or, when Jev needs state the file cannot show, a RULE.ts, and says which config context (typeAware, reads) it needs. It finds code in the repo for you to judge, with the lines around it, and drafts the rule from your answers; the draft is checked as adhere validate checks wording. A project rule is written to .adhere/rules/ or .adhere/oxlint/; an organization rule is pushed to the shared repo on a branch for a pull request.",
|
|
776
|
+
),
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
/** The shared rules repo to refresh instead of this checkout's, as `org/repo`. */
|
|
780
|
+
const sharedRepo = Argument.String("repo").pipe(
|
|
781
|
+
Argument.filterMap(
|
|
782
|
+
(spec) => Option.fromNullishOr(/^[\w.-]+\/[\w.-]+$/.test(spec) ? spec : undefined),
|
|
783
|
+
(spec) => `org/repo, as on GitHub, not ${spec}`,
|
|
784
|
+
),
|
|
785
|
+
Argument.optional,
|
|
786
|
+
Argument.withDescription(
|
|
787
|
+
"The shared rules repo to refresh, as org/repo, instead of this checkout or the org's standards repo this checkout's origin names.",
|
|
788
|
+
),
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* `adhere rules readme [org/repo]`: refresh the managed index in a shared
|
|
793
|
+
* rules repo's README from the rules and lint config checked out there. In a
|
|
794
|
+
* shared repo it writes that README; otherwise it clones the GitHub repo named
|
|
795
|
+
* here, or the org's standards repo this checkout's origin names, and pushes the refreshed
|
|
796
|
+
* README on a review branch for a pull request.
|
|
797
|
+
*/
|
|
798
|
+
const rulesReadmeCommand = Command.make("readme", { repo: sharedRepo }, ({ repo }) =>
|
|
799
|
+
Effect.gen(function* () {
|
|
800
|
+
const path = yield* Path.Path;
|
|
801
|
+
const root = path.resolve();
|
|
802
|
+
const named = Option.isSome(repo) ? repo.value : undefined;
|
|
803
|
+
if (named === undefined && (yield* isSharedRepo(root))) {
|
|
804
|
+
const refreshed = yield* refreshSharedReadme(root);
|
|
805
|
+
yield* Console.log(refreshed.message);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const origin = named === undefined ? yield* originUrlAt(root) : undefined;
|
|
809
|
+
// A consumer repo's shared rules live in its org's standards repo, never in the repo itself.
|
|
810
|
+
const target = named ?? (origin === undefined ? undefined : standardsRepoOf(origin));
|
|
811
|
+
if (target === undefined) {
|
|
812
|
+
return yield* ReadmeUnavailable.make({
|
|
813
|
+
message:
|
|
814
|
+
origin === undefined
|
|
815
|
+
? `${root} is not a shared rules repo, and git names no origin here, so there is no README to refresh. Run this in the shared repo, name it as adhere rules readme org/repo, or add a GitHub origin.`
|
|
816
|
+
: `${root} is not a shared rules repo, and its origin ${origin} is not a GitHub repo. Run this in the shared repo, or name one: adhere rules readme org/repo.`,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
const refreshed = yield* refreshRemoteReadme(target);
|
|
820
|
+
yield* Console.log(
|
|
821
|
+
refreshed.branch === undefined
|
|
822
|
+
? `${refreshed.message}\nNo change, so nothing was pushed.`
|
|
823
|
+
: [
|
|
824
|
+
refreshed.message,
|
|
825
|
+
`Pushed ${refreshed.branch} to ${refreshed.repo}.`,
|
|
826
|
+
`Open the pull request: https://github.com/${refreshed.repo}/compare/${refreshed.branch}?expand=1`,
|
|
827
|
+
].join("\n"),
|
|
828
|
+
);
|
|
829
|
+
}).pipe(Effect.provide(statusLayer)),
|
|
830
|
+
).pipe(
|
|
831
|
+
Command.withDescription(
|
|
832
|
+
"Refresh the managed index in a shared rules repo's README from .adhere/rules/ and oxlint/rules.json: each rule's link and description, each enabled native Oxlint setting, and each custom plugin's enabled rules. In the shared repo it writes that README. Elsewhere it refreshes the GitHub repo named as org/repo, or the org's standards repo this checkout's origin names, from a clone, pushes README.md alone on a review branch, and prints the pull request link; a current README pushes nothing. It preserves the rest of the README and refuses malformed markers.",
|
|
833
|
+
),
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
export const rulesCommand = Command.make("rules").pipe(
|
|
837
|
+
Command.withDescription("Work on this repo's rules."),
|
|
838
|
+
Command.withSubcommands([rulesCreateCommand, rulesReadmeCommand]),
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
export const postinstallCommand = Command.make("postinstall", {}, () =>
|
|
842
|
+
Effect.gen(function* () {
|
|
843
|
+
const path = yield* Path.Path;
|
|
844
|
+
yield* Console.log(yield* renderInstructions(path.resolve()));
|
|
845
|
+
}),
|
|
846
|
+
).pipe(
|
|
847
|
+
Command.withDescription(
|
|
848
|
+
"Render instruction files recursively into a managed block in AGENTS.md.",
|
|
849
|
+
),
|
|
850
|
+
);
|
|
851
|
+
|
|
559
852
|
/** The whole CLI. Bare `adhere` prints its help. */
|
|
560
853
|
export const cli = Command.make("adhere").pipe(
|
|
561
854
|
Command.withDescription("Lint a repository against the rules in .adhere/rules/, judged by Jev."),
|
|
562
855
|
Command.withExamples([
|
|
563
|
-
{
|
|
856
|
+
{
|
|
857
|
+
command: "adhere init",
|
|
858
|
+
description: "Scaffold config, tsconfig, and two example rules",
|
|
859
|
+
},
|
|
564
860
|
{
|
|
565
861
|
command: "adhere validate",
|
|
566
862
|
description: "Check the rules' wording, and ask Jev whether any contradict",
|
|
@@ -569,7 +865,7 @@ export const cli = Command.make("adhere").pipe(
|
|
|
569
865
|
{ command: "adhere lint", description: "Audit the working directory" },
|
|
570
866
|
{
|
|
571
867
|
command: "adhere install org/repo",
|
|
572
|
-
description: "Copy another repo's
|
|
868
|
+
description: "Copy another repo's instructions and rules into this one",
|
|
573
869
|
},
|
|
574
870
|
{
|
|
575
871
|
command: "adhere lint --preset effect",
|
|
@@ -580,10 +876,12 @@ export const cli = Command.make("adhere").pipe(
|
|
|
580
876
|
lintCommand,
|
|
581
877
|
validateCommand,
|
|
582
878
|
initCommand,
|
|
879
|
+
postinstallCommand,
|
|
583
880
|
listCommand,
|
|
584
881
|
installCommand,
|
|
585
882
|
loginCommand,
|
|
586
883
|
logoutCommand,
|
|
587
884
|
skillCommand,
|
|
885
|
+
rulesCommand,
|
|
588
886
|
]),
|
|
589
887
|
);
|