@drkmttr/adhere 0.2.2 → 0.3.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 CHANGED
@@ -9,10 +9,104 @@ 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. Paths under
82
+ `node_modules/`, `dist/`, `coverage/`, `vendor/`, `e2e/`, `references/`,
83
+ `.adhere/`, `.agents/`, `.claude/`, `.direnv/`, `.alchemy/`, or `.vite/` are
84
+ skipped. `.tsx` files are not read, and `.gitignore` is not consulted.
85
+
86
+ ### Init
87
+
88
+ `adhere init` writes `.adhere/config.ts` and two example rules. It is safe to
89
+ rerun: by default it reports existing files as skipped and does not clobber
90
+ them. `--force` overwrites them. A config already at another accepted path is
91
+ kept, and no second one is added, even with `--force`.
92
+
93
+ ### Validate
94
+
95
+ `adhere validate` loads the config, rule files, and presets the way `lint`
96
+ does, without calling Jev, so a file that fails to decode refuses here too, and
97
+ it prints how many rules it loaded. `--preset` adds a built-in rule set, as for
98
+ `lint`. It then checks the rules for local textual contradictions: it exits 1
99
+ and prints the conflicting rule files when overlapping rule scopes contain
100
+ opposite textual directives for the same topic, such as "Use service classes
101
+ for IO" and "Do not use service classes for IO". Same-id nested rules are
102
+ treated as intentional shadowing rather than contradictions.
103
+
12
104
  ## Config
13
105
 
14
- A config file in the working directory of the repo being audited, at one of
15
- these paths (keep one):
106
+ A config file is optional. Without one, adhere reads the rule files in
107
+ `.adhere/` and any `--preset`. A config names presets, sets the model and
108
+ thresholds, or gives rules inline. It sits in the working directory of the
109
+ repo being audited, at one of these paths (keep one):
16
110
 
17
111
  - `.adhere/config.ts`, the default, next to the rules
18
112
  - `adhere.config.ts`
@@ -40,9 +134,15 @@ type UserId = typeof UserId.Type
40
134
  ```
41
135
 
42
136
  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.
137
+ the run. The `import type` is erased at runtime. `defineConfig({...})` from
138
+ `@drkmttr/adhere` does the same thing as `satisfies Config`, and the executable
139
+ supplies it whether or not the repo has the package installed; the editor's
140
+ types come from the package as a dev dependency. A config can import other
141
+ files by relative path, but no packages besides `@drkmttr/adhere`: the
142
+ executable does not resolve `node_modules`.
143
+
144
+ `rules` in the config, inline as above or as a directory (below), replaces the
145
+ `.adhere/` rule files: none of them, root or nested, is read then.
46
146
 
47
147
  ### Rules as Markdown files
48
148
 
@@ -51,32 +151,6 @@ config and the cache (neither is read as a rule). The path without `.md` is the
51
151
  rule id, so `.adhere/data/brand-ports.md` is `data/brand-ports`. When that
52
152
  directory exists, it is read without any config.
53
153
 
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
154
  A file is front matter, then a body. The first fenced code block in the body
81
155
  is the reference; prose around it renders on GitHub and is ignored. Without a
82
156
  fence, the whole body is the reference.
@@ -101,6 +175,30 @@ const Port = Schema.Int.pipe(
101
175
  validation refuses the run with its path in the message. `loadRules(directory)`
102
176
  from the package root does the same load for your own tooling.
103
177
 
178
+ Nested `.adhere/` directories are also discovered. A rule in
179
+ `packages/api/.adhere/data/brand-ports.md` has the same id,
180
+ `data/brand-ports`, but applies only to files under `packages/api/`. Root
181
+ `.adhere/` rules apply project-wide. If a nested rule has the same id as a root
182
+ rule, the nearest containing `.adhere/` shadows the less-specific rule for that
183
+ subtree; outside that subtree, the root rule still applies. There is no broader
184
+ priority system: presets are global, project rules override preset rules with
185
+ the same id, and nested project rules override less-specific project rules with
186
+ the same id for files in their subtree.
187
+
188
+ `.adhere/` is the default rather than `docs/adhere/` because it keeps
189
+ everything adhere owns in one directory: the config and the cache are tool
190
+ state, not documentation, and would stay in `.adhere/` anyway. A nested
191
+ `.adhere/` also scopes its rules to the directory that contains it (above). The
192
+ cost is visibility: a dot directory is hidden from `ls`, and from `rg` without
193
+ `--hidden`. A repo that would rather keep its rules with the rest of its
194
+ documentation can point `rules` at `docs/adhere/`:
195
+
196
+ ```ts
197
+ export default { presets: ["effect"], rules: "./docs/adhere" } satisfies Config;
198
+ ```
199
+
200
+ Rules read through `rules` apply project-wide.
201
+
104
202
  ### Presets
105
203
 
106
204
  A preset has the shape of a config without `presets`: `rules`, and optionally
@@ -118,56 +216,52 @@ order (a later preset wins), then the defaults `jev-latest` and `0.7`. A rule
118
216
  in `rules` replaces a preset rule with the same id. A rule's own `threshold`
119
217
  beats all of the above for that rule.
120
218
 
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
219
+ ## How a file is judged
135
220
 
136
- From a checkout, with Bun:
221
+ 1. One Jev request per file, with the file's numbered lines and every rule as
222
+ the state, and one `noul` (yes/no probability) question per rule. Every
223
+ question shares the state cost of the request.
224
+ 2. A second request only when at least one rule's probability is above its
225
+ threshold: one `choice` question per flagged rule over the file's non-blank
226
+ lines, which yields the line to report. A file with more than 255 non-blank
227
+ lines is located in two steps (a block of 20 lines, then a line inside it).
228
+ Files longer than 5100 lines are skipped and counted in the summary.
137
229
 
138
- ```sh
139
- bun install
140
- bun link # puts `adhere` on PATH
141
- adhere --preset effect
142
- ```
230
+ ## Cache
143
231
 
144
- In a checkout no platform package is installed, so `bin/adhere.js` runs
145
- `src/main.ts` with Bun instead.
232
+ Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
233
+ per file. An entry stores the file's content hash and, per rule, the
234
+ probability, the located line, and a fingerprint of the rule's text and model.
235
+ A changed file re-judges every rule for that file. An edited rule re-judges
236
+ only that rule. A lowered threshold locates cached judgments that are newly
237
+ above it without judging again. Entries depend on content, not on the
238
+ machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
146
239
 
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.
240
+ `TYPESAFE_API_KEY` is read only when a request is about to be sent. A run
241
+ where every file is cached needs no key and no network.
150
242
 
151
- Initialize a new project with a config and example rules:
243
+ ## Agent skill
152
244
 
153
- ```sh
154
- adhere init
155
- adhere init --force # overwrite the scaffold files if they already exist
156
- ```
245
+ [`skills/adhere/SKILL.md`](./skills/adhere/SKILL.md) teaches an agent to
246
+ gather a repo's conventions into rule files, configure adhere, and calibrate
247
+ thresholds. Install it with the [skills](https://github.com/vercel-labs/skills)
248
+ CLI: `skills add darkmatter/adhere`. The binary carries the same file:
249
+ `adhere skill` prints it, so `adhere skill > .agents/skills/adhere/SKILL.md`
250
+ works without a checkout.
157
251
 
158
- The command is safe to rerun: by default it reports existing files as skipped
159
- and does not clobber them.
252
+ ## Development
160
253
 
161
- Check only this project's configured rules for local textual contradictions:
254
+ From a checkout, with Bun:
162
255
 
163
256
  ```sh
164
- adhere contradictions
257
+ bun install
258
+ bun link # puts `adhere` on PATH
259
+ adhere lint --preset effect
165
260
  ```
166
261
 
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.
262
+ In a checkout no platform package is installed, so `bin/adhere.js` runs
263
+ `src/main.ts` with Bun instead. `bun run typecheck` runs `tsc` and the linter,
264
+ and `bun run test` runs the tests.
171
265
 
172
266
  ### Native executable
173
267
 
@@ -180,7 +274,12 @@ machine and still loads the repo's `config.ts` and Markdown rules from disk.
180
274
  into its package under `dist/npm/`
181
275
  ([`scripts/npm-packages.ts`](./scripts/npm-packages.ts)).
182
276
 
183
- ## Release
277
+ Each platform's executable is its own npm package,
278
+ `@drkmttr/adhere-<platform>-<arch>`, limited by `os` and `cpu`, and an
279
+ optional dependency of `@drkmttr/adhere`, so an install fetches only its own
280
+ machine's. The package's `bin/adhere.js` finds it and runs it with Node.
281
+
282
+ ### Release
184
283
 
185
284
  Releases are cut by CI from pushed version tags. From a clean, up-to-date
186
285
  `main`, push the release tag:
@@ -202,40 +301,10 @@ The publish workflow verifies that `package.json` matches the release tag,
202
301
  builds the platform packages, and publishes each of them before
203
302
  `@drkmttr/adhere`, which it first lists them in as `optionalDependencies` at
204
303
  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.
304
+ No npm token is used, so a new package has to be published once by hand
305
+ before it can be given trusted publishers on npmjs.com. npm checks the
306
+ workflow that started the run, so every package has two: `release.yaml`, which
307
+ calls the publish workflow for tag releases, and `publish.yaml`, for manual
308
+ runs. The publish workflow can be run manually for an already-created release
309
+ tag if a publish needs to be retried; it skips platform packages whose version
310
+ is already on npm.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Lints a repository against reference code in .adhere/, judged by Jev.",
6
6
  "repository": {
@@ -61,10 +61,10 @@
61
61
  }
62
62
  },
63
63
  "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"
64
+ "@drkmttr/adhere-darwin-arm64": "0.3.0",
65
+ "@drkmttr/adhere-darwin-x64": "0.3.0",
66
+ "@drkmttr/adhere-linux-arm64": "0.3.0",
67
+ "@drkmttr/adhere-linux-x64": "0.3.0",
68
+ "@drkmttr/adhere-win32-x64": "0.3.0"
69
69
  }
70
70
  }
@@ -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/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,
package/test/cli.test.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { promisify } from "node:util";
3
6
  import packageJson from "../package.json";
4
7
  import { describe, expect, it } from "vite-plus/test";
5
8
 
6
9
  const execFileAsync = promisify(execFile);
10
+ const main = join(process.cwd(), "src", "main.ts");
7
11
 
8
12
  describe("cli", () => {
9
13
  it("reports the package version", async () => {
@@ -14,4 +18,28 @@ describe("cli", () => {
14
18
  expect(stderr).toBe("");
15
19
  expect(stdout.trim()).toBe(`adhere v${packageJson.version}`);
16
20
  });
21
+
22
+ it("prints help listing every command when run bare", async () => {
23
+ const { stdout } = await execFileAsync("bun", [main], { cwd: process.cwd() });
24
+ const subcommands = stdout.slice(stdout.indexOf("SUBCOMMANDS"), stdout.indexOf("EXAMPLES"));
25
+
26
+ for (const name of ["lint", "validate", "init", "skill"]) {
27
+ expect(subcommands).toMatch(new RegExp(`^ ${name} `, "m"));
28
+ }
29
+ });
30
+
31
+ it("validates a repo's rules without calling Jev", async () => {
32
+ const root = join(tmpdir(), `adhere-validate-${Date.now()}`);
33
+ await mkdir(join(root, ".adhere"), { recursive: true });
34
+ await writeFile(
35
+ join(root, ".adhere", "ports.md"),
36
+ '---\ndescription: A port is a branded integer.\n---\n\nconst Port = Schema.Int.pipe(Schema.brand("Port"))\n',
37
+ "utf8",
38
+ );
39
+
40
+ const { stdout } = await execFileAsync("bun", [main, "validate"], { cwd: root });
41
+
42
+ expect(stdout).toContain("1 rule loaded.");
43
+ expect(stdout).toContain("No contradictions found among configured rules.");
44
+ });
17
45
  });