@drkmttr/adhere 0.2.2 → 0.3.1

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 CHANGED
@@ -9,10 +9,105 @@ report uses the same frame as `vp lint`.
9
9
 
10
10
  Deterministic rules (substring matches, type checks) belong in a normal linter.
11
11
 
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install --global @drkmttr/adhere # or: bun add --global @drkmttr/adhere
16
+ ```
17
+
18
+ To pin the version in a repo, for CI or scripts, add it as a dev dependency
19
+ and run `npx adhere`. To try it without installing, run
20
+ `npx @drkmttr/adhere` or `bunx @drkmttr/adhere`.
21
+
22
+ `adhere` is a prebuilt executable for macOS and Linux on arm64 and x64 and for
23
+ Windows on x64. It needs Node to start, and no Bun.
24
+
25
+ adhere sends each file it judges to Jev at `api.typesafe.ai`, authenticated
26
+ with a TypeSafe AI API key in `TYPESAFE_API_KEY`.
27
+
28
+ ## Quick start
29
+
30
+ ```sh
31
+ adhere init # .adhere/config.ts and two example rules
32
+ adhere validate # check the config and rules, without Jev
33
+ echo ".adhere/cache/" >> .gitignore
34
+ export TYPESAFE_API_KEY=...
35
+ adhere lint # audit the working directory
36
+ ```
37
+
38
+ Without a config or rules, `adhere lint --preset effect` audits against the
39
+ built-in Effect rules. A finding looks like this:
40
+
41
+ ```text
42
+ × data/brand-ports (0.93): A port is a branded, range-checked integer, not a bare number.
43
+ ╭─[src/server.ts:4:1]
44
+ 4 │ const port: number = Number(process.env.PORT ?? 3000);
45
+ · ──────────────────────────────────────────────────────
46
+ ╰────
47
+ hint: const Port = Schema.Int.pipe(
48
+ Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
49
+ Schema.brand("Port"),
50
+ );
51
+
52
+ Found 1 error.
53
+ 42 files, 3 judged, 39 cached.
54
+ ```
55
+
56
+ The header is the rule id, Jev's probability, and the rule's description. The
57
+ hint is the rule's reference. The exit code is 0 when nothing is reported and 1
58
+ when something is. It is also 1 when the run refuses, for example on an invalid
59
+ config or rule file, a missing `TYPESAFE_API_KEY`, or an unknown command or
60
+ flag; a refusal prints its reason.
61
+
62
+ ## Usage
63
+
64
+ ```sh
65
+ adhere lint # audit the working directory
66
+ adhere lint --preset effect # add a built-in rule set; the config becomes optional
67
+ adhere lint --threshold 0.8 # replace the config's threshold; per-rule thresholds still apply
68
+ adhere validate # load the config and rules without Jev, check for contradictions
69
+ adhere init [--force] # scaffold .adhere/config.ts and two example rules
70
+ adhere skill # print the agent skill (below)
71
+ ```
72
+
73
+ Bare `adhere` prints the help, which lists the commands, and
74
+ `adhere <command> --help` lists a command's flags. `adhere --completions <shell>`
75
+ prints a completion script.
76
+
77
+ ### What gets read
78
+
79
+ `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
80
+ `.test.ts`, and config files. When the working directory contains `agents/`,
81
+ `apps/`, or `packages/`, only those trees are read. Below the working
82
+ directory, anything under `node_modules/`, `dist/`, `coverage/`, `vendor/`,
83
+ `e2e/`, `references/`, `.adhere/`, `.agents/`, `.claude/`, `.direnv/`,
84
+ `.alchemy/`, or `.vite/` is skipped; the directories above it do not count.
85
+ `.tsx` files are not read, and `.gitignore` is not consulted.
86
+
87
+ ### Init
88
+
89
+ `adhere init` writes `.adhere/config.ts` and two example rules. It is safe to
90
+ rerun: by default it reports existing files as skipped and does not clobber
91
+ them. `--force` overwrites them. A config already at another accepted path is
92
+ kept, and no second one is added, even with `--force`.
93
+
94
+ ### Validate
95
+
96
+ `adhere validate` loads the config, rule files, and presets the way `lint`
97
+ does, without calling Jev, so a file that fails to decode refuses here too, and
98
+ it prints how many rules it loaded. `--preset` adds a built-in rule set, as for
99
+ `lint`. It then checks the rules for local textual contradictions: it exits 1
100
+ and prints the conflicting rule files when overlapping rule scopes contain
101
+ opposite textual directives for the same topic, such as "Use service classes
102
+ for IO" and "Do not use service classes for IO". Same-id nested rules are
103
+ treated as intentional shadowing rather than contradictions.
104
+
12
105
  ## Config
13
106
 
14
- A config file in the working directory of the repo being audited, at one of
15
- these paths (keep one):
107
+ A config file is optional. Without one, adhere reads the rule files in
108
+ `.adhere/` and any `--preset`. A config names presets, sets the model and
109
+ thresholds, or gives rules inline. It sits in the working directory of the
110
+ repo being audited, at one of these paths (keep one):
16
111
 
17
112
  - `.adhere/config.ts`, the default, next to the rules
18
113
  - `adhere.config.ts`
@@ -40,9 +135,15 @@ type UserId = typeof UserId.Type
40
135
  ```
41
136
 
42
137
  The default export is decoded with Effect `Schema`. An invalid shape refuses
43
- the run. The `import type` is erased at runtime, so the file also loads under
44
- the native executable, where `@drkmttr/adhere` is not installed. With the
45
- package installed, `defineConfig({...})` does the same thing.
138
+ the run. The `import type` is erased at runtime. `defineConfig({...})` from
139
+ `@drkmttr/adhere` does the same thing as `satisfies Config`, and the executable
140
+ supplies it whether or not the repo has the package installed; the editor's
141
+ types come from the package as a dev dependency. A config can import other
142
+ files by relative path, but no packages besides `@drkmttr/adhere`: the
143
+ executable does not resolve `node_modules`.
144
+
145
+ `rules` in the config, inline as above or as a directory (below), replaces the
146
+ `.adhere/` rule files: none of them, root or nested, is read then.
46
147
 
47
148
  ### Rules as Markdown files
48
149
 
@@ -51,32 +152,6 @@ config and the cache (neither is read as a rule). The path without `.md` is the
51
152
  rule id, so `.adhere/data/brand-ports.md` is `data/brand-ports`. When that
52
153
  directory exists, it is read without any config.
53
154
 
54
- `.adhere/` is the default rather than `docs/adhere/` because it keeps
55
- everything adhere owns in one directory: the config and the cache are tool
56
- state, not documentation, and would stay in `.adhere/` anyway. A nested
57
- `.adhere/` also scopes its rules to the directory that contains it (below). The
58
- cost is visibility: a dot directory is hidden from `ls`, and from `rg` without
59
- `--hidden`. A repo that would rather keep its rules with the rest of its
60
- documentation can point `rules` at `docs/adhere/`:
61
-
62
- ```ts
63
- export default { presets: ["effect"], rules: "./docs/adhere" } satisfies Config;
64
- ```
65
-
66
- Rules read through `rules` apply project-wide; nested `.adhere/` directories
67
- are not read then. A config is otherwise needed only to name presets or set
68
- thresholds.
69
-
70
- Nested `.adhere/` directories are also discovered. A rule in
71
- `packages/api/.adhere/data/brand-ports.md` has the same id,
72
- `data/brand-ports`, but applies only to files under `packages/api/`. Root
73
- `.adhere/` rules apply project-wide. If a nested rule has the same id as a root
74
- rule, the nearest containing `.adhere/` shadows the less-specific rule for that
75
- subtree; outside that subtree, the root rule still applies. There is no broader
76
- priority system: presets are global, project rules override preset rules with
77
- the same id, and nested project rules override less-specific project rules with
78
- the same id for files in their subtree.
79
-
80
155
  A file is front matter, then a body. The first fenced code block in the body
81
156
  is the reference; prose around it renders on GitHub and is ignored. Without a
82
157
  fence, the whole body is the reference.
@@ -101,6 +176,31 @@ const Port = Schema.Int.pipe(
101
176
  validation refuses the run with its path in the message. `loadRules(directory)`
102
177
  from the package root does the same load for your own tooling.
103
178
 
179
+ Nested `.adhere/` directories are also discovered, except under
180
+ `node_modules/`, `dist/`, and the other skipped directories (above). A rule in
181
+ `packages/api/.adhere/data/brand-ports.md` has the same id,
182
+ `data/brand-ports`, but applies only to files under `packages/api/`. Root
183
+ `.adhere/` rules apply project-wide. If a nested rule has the same id as a root
184
+ rule, the nearest containing `.adhere/` shadows the less-specific rule for that
185
+ subtree; outside that subtree, the root rule still applies. There is no broader
186
+ priority system: presets are global, project rules override preset rules with
187
+ the same id, and nested project rules override less-specific project rules with
188
+ the same id for files in their subtree.
189
+
190
+ `.adhere/` is the default rather than `docs/adhere/` because it keeps
191
+ everything adhere owns in one directory: the config and the cache are tool
192
+ state, not documentation, and would stay in `.adhere/` anyway. A nested
193
+ `.adhere/` also scopes its rules to the directory that contains it (above). The
194
+ cost is visibility: a dot directory is hidden from `ls`, and from `rg` without
195
+ `--hidden`. A repo that would rather keep its rules with the rest of its
196
+ documentation can point `rules` at `docs/adhere/`:
197
+
198
+ ```ts
199
+ export default { presets: ["effect"], rules: "./docs/adhere" } satisfies Config;
200
+ ```
201
+
202
+ Rules read through `rules` apply project-wide.
203
+
104
204
  ### Presets
105
205
 
106
206
  A preset has the shape of a config without `presets`: `rules`, and optionally
@@ -118,56 +218,52 @@ order (a later preset wins), then the defaults `jev-latest` and `0.7`. A rule
118
218
  in `rules` replaces a preset rule with the same id. A rule's own `threshold`
119
219
  beats all of the above for that rule.
120
220
 
121
- ## Install
122
-
123
- ```sh
124
- npm install --global @drkmttr/adhere # or: bun add --global @drkmttr/adhere
125
- ```
126
-
127
- `adhere` is the native executable (below), compiled for macOS and Linux on
128
- arm64 and x64 and for Windows on x64. Each platform's is its own package,
129
- `@drkmttr/adhere-<platform>-<arch>`, and an optional dependency of
130
- `@drkmttr/adhere` that the install fetches only on that platform. The package's
131
- `bin/adhere.js` runs it with Node, so neither Bun nor adhere's runtime
132
- dependencies are needed.
133
-
134
- ## Run
221
+ ## How a file is judged
135
222
 
136
- From a checkout, with Bun:
223
+ 1. One Jev request per file, with the file's numbered lines and every rule as
224
+ the state, and one `noul` (yes/no probability) question per rule. Every
225
+ question shares the state cost of the request.
226
+ 2. A second request only when at least one rule's probability is above its
227
+ threshold: one `choice` question per flagged rule over the file's non-blank
228
+ lines, which yields the line to report. A file with more than 255 non-blank
229
+ lines is located in two steps (a block of 20 lines, then a line inside it).
230
+ Files longer than 5100 lines are skipped and counted in the summary.
137
231
 
138
- ```sh
139
- bun install
140
- bun link # puts `adhere` on PATH
141
- adhere --preset effect
142
- ```
232
+ ## Cache
143
233
 
144
- In a checkout no platform package is installed, so `bin/adhere.js` runs
145
- `src/main.ts` with Bun instead.
234
+ Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
235
+ per file. An entry stores the file's content hash and, per rule, the
236
+ probability, the located line, and a fingerprint of the rule's text and model.
237
+ A changed file re-judges every rule for that file. An edited rule re-judges
238
+ only that rule. A lowered threshold locates cached judgments that are newly
239
+ above it without judging again. Entries depend on content, not on the
240
+ machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
146
241
 
147
- `adhere` audits the working directory. When that directory contains `agents/`,
148
- `apps/`, or `packages/`, only those trees are read. Exit code 1 when there is
149
- at least one finding.
242
+ `TYPESAFE_API_KEY` is read only when a request is about to be sent. A run
243
+ where every file is cached needs no key and no network.
150
244
 
151
- Initialize a new project with a config and example rules:
245
+ ## Agent skill
152
246
 
153
- ```sh
154
- adhere init
155
- adhere init --force # overwrite the scaffold files if they already exist
156
- ```
247
+ [`skills/adhere/SKILL.md`](./skills/adhere/SKILL.md) teaches an agent to
248
+ gather a repo's conventions into rule files, configure adhere, and calibrate
249
+ thresholds. Install it with the [skills](https://github.com/vercel-labs/skills)
250
+ CLI: `skills add darkmatter/adhere`. The binary carries the same file:
251
+ `adhere skill` prints it, so `adhere skill > .agents/skills/adhere/SKILL.md`
252
+ works without a checkout.
157
253
 
158
- The command is safe to rerun: by default it reports existing files as skipped
159
- and does not clobber them.
254
+ ## Development
160
255
 
161
- Check only this project's configured rules for local textual contradictions:
256
+ From a checkout, with Bun:
162
257
 
163
258
  ```sh
164
- adhere contradictions
259
+ bun install
260
+ bun link # puts `adhere` on PATH
261
+ adhere lint --preset effect
165
262
  ```
166
263
 
167
- It exits 1 and prints the conflicting rule files when overlapping rule scopes
168
- contain opposite textual directives for the same topic, such as "Use service
169
- classes for IO" and "Do not use service classes for IO". Same-id nested rules
170
- are treated as intentional shadowing rather than contradictions.
264
+ In a checkout no platform package is installed, so `bin/adhere.js` runs
265
+ `src/main.ts` with Bun instead. `bun run typecheck` runs `tsc` and the linter,
266
+ and `bun run test` runs the tests.
171
267
 
172
268
  ### Native executable
173
269
 
@@ -180,7 +276,12 @@ machine and still loads the repo's `config.ts` and Markdown rules from disk.
180
276
  into its package under `dist/npm/`
181
277
  ([`scripts/npm-packages.ts`](./scripts/npm-packages.ts)).
182
278
 
183
- ## Release
279
+ Each platform's executable is its own npm package,
280
+ `@drkmttr/adhere-<platform>-<arch>`, limited by `os` and `cpu`, and an
281
+ optional dependency of `@drkmttr/adhere`, so an install fetches only its own
282
+ machine's. The package's `bin/adhere.js` finds it and runs it with Node.
283
+
284
+ ### Release
184
285
 
185
286
  Releases are cut by CI from pushed version tags. From a clean, up-to-date
186
287
  `main`, push the release tag:
@@ -202,40 +303,11 @@ The publish workflow verifies that `package.json` matches the release tag,
202
303
  builds the platform packages, and publishes each of them before
203
304
  `@drkmttr/adhere`, which it first lists them in as `optionalDependencies` at
204
305
  the same version. Every publish is `npm publish --access public --provenance`.
205
- No npm token is used, so a new platform package has to be published once by
206
- hand and given the trusted publisher on npmjs.com before the job can publish
207
- it. The publish workflow can also be run manually for an already-created
208
- release tag if a publish needs to be retried; it skips platform packages whose
209
- version is already on npm.
210
-
211
- ## How a file is judged
212
-
213
- 1. One Jev request per file, with the file's numbered lines and every rule as
214
- the state, and one `noul` (yes/no probability) question per rule. Every
215
- question shares the state cost of the request.
216
- 2. A second request only when at least one rule's probability is above its
217
- threshold: one `choice` question per flagged rule over the file's non-blank
218
- lines, which yields the line to report. A file with more than 255 non-blank
219
- lines is located in two steps (a block of 20 lines, then a line inside it).
220
- Files longer than 5100 lines are skipped and counted in the summary.
221
-
222
- ## Cache
223
-
224
- Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
225
- per file. An entry stores the file's content hash and, per rule, the
226
- probability, the located line, and a fingerprint of the rule's text and model.
227
- A changed file re-judges every rule for that file. An edited rule re-judges
228
- only that rule. A lowered threshold locates cached judgments that are newly
229
- above it without judging again.
230
-
231
- `TYPESAFE_API_KEY` is read only when a request is about to be sent. A run
232
- where every file is cached needs no key and no network.
233
-
234
- ## Agent skill
235
-
236
- [`skills/adhere/SKILL.md`](./skills/adhere/SKILL.md) teaches an agent to
237
- gather a repo's conventions into rule files, configure adhere, and calibrate
238
- thresholds. Install it with the [skills](https://github.com/vercel-labs/skills)
239
- CLI: `skills add darkmatter/adhere`. The binary carries the same file:
240
- `adhere skill` prints it, so `adhere skill > .agents/skills/adhere/SKILL.md`
241
- works without a checkout.
306
+ No npm token is used, so a new package has to be published once by hand
307
+ before it can be given trusted publishers on npmjs.com. npm checks the
308
+ workflow that started the run, so every package has two: `release.yaml`, which
309
+ calls the publish workflow for tag releases, and `publish.yaml`, for manual
310
+ runs. The publish workflow can be run manually for an already-created release
311
+ tag if a publish needs to be retried; it skips every package whose version is
312
+ already on npm. The main package's `files` list keeps it to `bin/`, `src/`,
313
+ `presets/`, and `skills/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "Lints a repository against reference code in .adhere/, judged by Jev.",
6
6
  "repository": {
@@ -17,6 +17,12 @@
17
17
  "exports": {
18
18
  ".": "./src/index.ts"
19
19
  },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "presets",
24
+ "skills"
25
+ ],
20
26
  "publishConfig": {
21
27
  "access": "public"
22
28
  },
@@ -61,10 +67,10 @@
61
67
  }
62
68
  },
63
69
  "optionalDependencies": {
64
- "@drkmttr/adhere-darwin-arm64": "0.2.2",
65
- "@drkmttr/adhere-darwin-x64": "0.2.2",
66
- "@drkmttr/adhere-linux-arm64": "0.2.2",
67
- "@drkmttr/adhere-linux-x64": "0.2.2",
68
- "@drkmttr/adhere-win32-x64": "0.2.2"
70
+ "@drkmttr/adhere-darwin-arm64": "0.3.1",
71
+ "@drkmttr/adhere-darwin-x64": "0.3.1",
72
+ "@drkmttr/adhere-linux-arm64": "0.3.1",
73
+ "@drkmttr/adhere-linux-x64": "0.3.1",
74
+ "@drkmttr/adhere-win32-x64": "0.3.1"
69
75
  }
70
76
  }
@@ -38,7 +38,7 @@ Keep rules about intent, shape, and placement:
38
38
  - "Business logic depends on a config service, not on `Config.*` reads."
39
39
  - "Domain errors are `Schema.TaggedError` classes, not thrown `Error`."
40
40
 
41
- Skip rules a preset already covers. Run `adhere --help` and read
41
+ Skip rules a preset already covers. Run `adhere lint --help` and read
42
42
  `presets/effect/` in the adhere repo before writing Effect rules; extend with
43
43
  `presets: ["effect"]` and add only what is specific to this repo.
44
44
 
@@ -95,8 +95,9 @@ uncached files; in this org it comes from SOPS via `just shell`.
95
95
  ## 5. Run and calibrate
96
96
 
97
97
  ```sh
98
- adhere # or: adhere --preset effect
99
- adhere --threshold 0.9 # re-reads the cache, sends nothing
98
+ adhere validate # decodes the config and rules, checks contradictions
99
+ adhere lint # or: adhere lint --preset effect
100
+ adhere lint --threshold 0.9 # re-reads the cache, sends nothing
100
101
  ```
101
102
 
102
103
  Then, per rule, read the findings and decide:
package/src/cli.ts CHANGED
@@ -6,7 +6,7 @@ import { render, runAudit } from "#workflows/audit.ts";
6
6
  import type { Overrides } from "#config.ts";
7
7
  import { findContradictions, formatContradictions } from "#contradictions.ts";
8
8
  import { initProject } from "#init.ts";
9
- import { presetNames } from "#presets.ts";
9
+ import { type PresetName, presetNames } from "#presets.ts";
10
10
  import { globalRuleSet } from "#rules.ts";
11
11
  import {
12
12
  Console,
@@ -39,13 +39,6 @@ class ContradictionsReported extends Schema.TaggedError<ContradictionsReported>(
39
39
  readonly [Runtime.errorReported] = false;
40
40
  }
41
41
 
42
- class UsageReported extends Schema.TaggedError<UsageReported>()(
43
- "UsageReported",
44
- { message: Schema.String },
45
- ) {
46
- readonly [Runtime.errorReported] = false;
47
- }
48
-
49
42
  const preset = Flag.choice("preset", presetNames).pipe(
50
43
  Flag.optional,
51
44
  Flag.withDescription("Add a built-in rule set. With a preset, .adhere/config.ts is optional."),
@@ -58,6 +51,14 @@ const threshold = Flag.float("threshold").pipe(
58
51
  ),
59
52
  );
60
53
 
54
+ const force = Flag.boolean("force").pipe(
55
+ Flag.withDefault(false),
56
+ Flag.withDescription("Overwrite existing scaffold files."),
57
+ );
58
+
59
+ const chosenPresets = (flag: Option.Option<PresetName>): ReadonlyArray<PresetName> =>
60
+ Option.isSome(flag) ? [flag.value] : [];
61
+
61
62
  export const auditLayer = (overrides: Overrides) =>
62
63
  Layer.mergeAll(
63
64
  SourceWalkerLive,
@@ -65,14 +66,8 @@ export const auditLayer = (overrides: Overrides) =>
65
66
  JevLive.pipe(Layer.provide(FetchHttpClient.layer)),
66
67
  ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
67
68
 
68
- /** `adhere skill`: the agent skill for writing rules, as shipped in the binary. */
69
- export const skillCommand = Command.make("skill", {}, () => Console.log(skill.trimEnd())).pipe(
70
- Command.withDescription(
71
- "Print the agent skill that gathers a repo's conventions into rules and configures adhere. Pipe it into .agents/skills/adhere/SKILL.md or hand it to an agent.",
72
- ),
73
- );
74
-
75
- const audit = Command.make("adhere", { preset, threshold }, () =>
69
+ /** `adhere lint`: the audit. */
70
+ export const lintCommand = Command.make("lint", { preset, threshold }, () =>
76
71
  Effect.gen(function* () {
77
72
  const result = yield* runAudit;
78
73
  const stdio = yield* Stdio.Stdio;
@@ -90,85 +85,66 @@ const audit = Command.make("adhere", { preset, threshold }, () =>
90
85
  ),
91
86
  Command.provide((input) =>
92
87
  auditLayer({
93
- presets: Option.isSome(input.preset) ? [input.preset.value] : [],
88
+ presets: chosenPresets(input.preset),
94
89
  threshold: Option.getOrUndefined(input.threshold),
95
90
  }),
96
91
  ),
97
92
  );
98
93
 
99
- const INIT_HELP = `Usage: adhere init [--force]
100
-
101
- Scaffold .adhere/config.ts and example Markdown rules.
102
-
103
- Options:
104
- --force Overwrite existing scaffold files
105
- --help Show this help
106
-
107
- Examples:
108
- adhere init
109
- adhere init --force`;
110
-
111
- const CONTRADICTIONS_HELP = `Usage: adhere contradictions
112
-
113
- Check this project's configured adhere rules for local textual contradictions.
114
-
115
- Examples:
116
- adhere contradictions`;
117
-
118
- export const runInitCommand = (args: ReadonlyArray<string>) =>
94
+ /**
95
+ * `adhere validate`: everything `lint` loads, without Jev. A config, rule file,
96
+ * or preset that does not decode refuses here the way it would refuse `lint`.
97
+ */
98
+ export const validateCommand = Command.make("validate", { preset }, () =>
119
99
  Effect.gen(function* () {
120
- if (args.includes("--help") || args.includes("-h")) {
121
- yield* Console.log(INIT_HELP);
122
- return;
123
- }
124
- const unknown = args.filter((arg) => arg !== "--force");
125
- if (unknown.length > 0) {
126
- yield* Console.error(`Unknown option for adhere init: ${unknown.join(", ")}`);
127
- yield* Console.error(INIT_HELP);
128
- return yield* UsageReported.make({ message: "invalid init arguments" });
100
+ const config = yield* AdhereConfig;
101
+ const path = yield* Path.Path;
102
+ const entries = config.scopedRules ?? globalRuleSet(config.rules, path.resolve());
103
+ const contradictions = findContradictions(entries);
104
+ const loaded = `${entries.length} ${entries.length === 1 ? "rule" : "rules"} loaded.`;
105
+ yield* Console.log([loaded, formatContradictions(contradictions)].join("\n"));
106
+ if (contradictions.length > 0) {
107
+ return yield* ContradictionsReported.make({ count: contradictions.length });
129
108
  }
109
+ }),
110
+ ).pipe(
111
+ Command.withDescription(
112
+ "Load the config, rule files, and presets the way lint does, without calling Jev, and check the rules for contradictions.",
113
+ ),
114
+ Command.provide((input) => AdhereConfigLive({ presets: chosenPresets(input.preset) })),
115
+ );
116
+
117
+ /** `adhere init`: the scaffold. */
118
+ export const initCommand = Command.make("init", { force }, (input) =>
119
+ Effect.gen(function* () {
130
120
  const path = yield* Path.Path;
131
- const result = yield* initProject(path.resolve(), {
132
- force: args.includes("--force"),
133
- });
121
+ const result = yield* initProject(path.resolve(), { force: input.force });
134
122
  const lines = [
135
- result.created.length > 0
136
- ? `created: ${result.created.join(", ")}`
137
- : "created: none",
138
- result.skipped.length > 0
139
- ? `skipped: ${result.skipped.join(", ")}`
140
- : "skipped: none",
123
+ result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
124
+ result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
141
125
  ];
142
126
  yield* Console.log(lines.join("\n"));
143
- });
127
+ }),
128
+ ).pipe(Command.withDescription("Scaffold .adhere/config.ts and example Markdown rules."));
144
129
 
145
- export const runContradictionsCommand = (args: ReadonlyArray<string>) =>
146
- Effect.gen(function* () {
147
- if (args.includes("--help") || args.includes("-h")) {
148
- yield* Console.log(CONTRADICTIONS_HELP);
149
- return;
150
- }
151
- if (args.length > 0) {
152
- yield* Console.error(
153
- `Unknown option for adhere contradictions: ${args.join(", ")}`,
154
- );
155
- yield* Console.error(CONTRADICTIONS_HELP);
156
- return yield* UsageReported.make({
157
- message: "invalid contradictions arguments",
158
- });
159
- }
160
- const config = yield* AdhereConfig;
161
- const path = yield* Path.Path;
162
- const root = path.resolve();
163
- const entries = config.scopedRules ?? globalRuleSet(config.rules, root);
164
- const contradictions = findContradictions(entries);
165
- yield* Console.log(formatContradictions(contradictions));
166
- if (contradictions.length > 0) {
167
- return yield* ContradictionsReported.make({
168
- count: contradictions.length,
169
- });
170
- }
171
- });
130
+ /** `adhere skill`: the agent skill for writing rules, as shipped in the binary. */
131
+ export const skillCommand = Command.make("skill", {}, () => Console.log(skill.trimEnd())).pipe(
132
+ Command.withDescription(
133
+ "Print the agent skill that gathers a repo's conventions into rules and configures adhere. Pipe it into .agents/skills/adhere/SKILL.md or hand it to an agent.",
134
+ ),
135
+ );
172
136
 
173
- /** The whole CLI: `adhere` audits, `adhere skill` prints the skill. */
174
- export const auditCommand = audit.pipe(Command.withSubcommands([skillCommand]));
137
+ /** The whole CLI. Bare `adhere` prints its help. */
138
+ export const cli = Command.make("adhere").pipe(
139
+ Command.withDescription("Lint a repository against reference code in .adhere/, judged by Jev."),
140
+ Command.withExamples([
141
+ { command: "adhere init", description: "Scaffold .adhere/config.ts and two example rules" },
142
+ { command: "adhere validate", description: "Check the config and rules without calling Jev" },
143
+ { command: "adhere lint", description: "Audit the working directory" },
144
+ {
145
+ command: "adhere lint --preset effect",
146
+ description: "Audit against the built-in Effect rules",
147
+ },
148
+ ]),
149
+ Command.withSubcommands([lintCommand, validateCommand, initCommand, skillCommand]),
150
+ );
package/src/config.ts CHANGED
@@ -38,6 +38,25 @@ export const CONFIG_FILES = [
38
38
  "adhere.config.ts",
39
39
  ".adhere.config.ts",
40
40
  ] as const;
41
+ /**
42
+ * Directories never read into, for source files or for nested `.adhere/`
43
+ * rules: dependency, generated, vendored, and tool trees. Matched as whole
44
+ * path segments below the working directory, so a repo that itself sits under
45
+ * one of these names is still read.
46
+ */
47
+ export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
48
+ "node_modules",
49
+ "dist",
50
+ ".agents",
51
+ ".claude",
52
+ ".direnv",
53
+ ".alchemy",
54
+ "coverage",
55
+ ".vite",
56
+ "references",
57
+ "vendor",
58
+ "e2e",
59
+ ]);
41
60
  export interface AdhereConfig extends Schema.Schema.Type<typeof AdhereConfig> {}
42
61
 
43
62
  /** The shape a config file default-exports. */
package/src/main.ts CHANGED
@@ -1,21 +1,23 @@
1
1
  #!/usr/bin/env bun
2
- import { auditCommand, runContradictionsCommand, runInitCommand } from "#cli.ts";
3
- import { AdhereConfigLive } from "#services/AdhereConfig.ts";
2
+ import { cli } from "#cli.ts";
3
+ import * as adhere from "#index.ts";
4
4
  import { version } from "#version.ts";
5
5
  import { BunRuntime, BunServices } from "@effect/platform-bun";
6
+ import { plugin } from "bun";
6
7
  import { Effect } from "effect";
7
8
  import { Command } from "effect/unstable/cli";
8
9
 
9
- const [subcommand, ...args] = Bun.argv.slice(2);
10
+ // A config file may import `@drkmttr/adhere`, for `defineConfig`. The compiled
11
+ // executable resolves no packages from disk, so it answers that import with
12
+ // its own copy, whether or not the audited repo has the package installed.
13
+ plugin({
14
+ name: "@drkmttr/adhere",
15
+ setup: (build) => {
16
+ build.module("@drkmttr/adhere", () => ({ exports: { ...adhere }, loader: "object" }));
17
+ },
18
+ });
10
19
 
11
- const program =
12
- subcommand === "init"
13
- ? runInitCommand(args)
14
- : subcommand === "contradictions"
15
- ? runContradictionsCommand(args).pipe(Effect.provide(AdhereConfigLive({})))
16
- : Command.run(auditCommand, { version });
17
-
18
- program.pipe(
20
+ Command.run(cli, { version }).pipe(
19
21
  // oxlint-disable-next-line effecttsgo/strict-effect-provide -- this executable entrypoint supplies Bun's process services.
20
22
  Effect.provide(BunServices.layer),
21
23
  BunRuntime.runMain,