@drkmttr/adhere 0.2.1 → 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.
@@ -1,4 +1,4 @@
1
- import type { Config } from "./src/index.ts";
1
+ import type { Config } from "../src/index.ts";
2
2
 
3
3
  // Type-only import: erased at runtime, so the compiled binary loads this file
4
4
  // without resolving the package (see README, "Configuration").
@@ -16,4 +16,12 @@ jobs:
16
16
  - run: bun install --frozen-lockfile
17
17
  - run: bun run typecheck
18
18
  - run: bun run test
19
- - run: bun run build
19
+ - run: bun run build:npm
20
+ - name: Run the launcher against the linux-x64 package
21
+ run: |
22
+ # Outside the checkout, where the launcher has no source to fall back to.
23
+ launcher="$RUNNER_TEMP/launcher"
24
+ mkdir -p "$launcher/node_modules/@drkmttr"
25
+ cp -r bin "$launcher/bin"
26
+ ln -s "$PWD/dist/npm/linux-x64" "$launcher/node_modules/@drkmttr/adhere-linux-x64"
27
+ node "$launcher/bin/adhere.js" --version
@@ -1,8 +1,18 @@
1
1
  name: Publish
2
2
 
3
3
  on:
4
- release:
5
- types: [published]
4
+ workflow_call:
5
+ inputs:
6
+ tag_name:
7
+ description: "Release tag to publish, for example v0.3.0."
8
+ required: true
9
+ type: string
10
+ workflow_dispatch:
11
+ inputs:
12
+ tag_name:
13
+ description: "Release tag to publish, for example v0.3.0."
14
+ required: true
15
+ type: string
6
16
 
7
17
  permissions:
8
18
  id-token: write
@@ -23,15 +33,36 @@ jobs:
23
33
  node-version: "24"
24
34
  registry-url: "https://registry.npmjs.org"
25
35
  - name: Verify release tag matches package version
36
+ env:
37
+ TAG_NAME: ${{ inputs.tag_name }}
26
38
  run: |
27
- version="${GITHUB_REF_NAME#v}"
39
+ version="${TAG_NAME#v}"
40
+ if [[ "$version" == "$TAG_NAME" || ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.+][0-9A-Za-z.-]+)?$ ]]; then
41
+ echo "Release tags must look like vX.Y.Z, optionally with a prerelease/build suffix." >&2
42
+ exit 1
43
+ fi
28
44
  package_version="$(node -p "require('./package.json').version")"
29
45
  if [ "$version" != "$package_version" ]; then
30
- echo "Release tag $GITHUB_REF_NAME does not match package.json version $package_version." >&2
46
+ echo "Release tag $TAG_NAME does not match package.json version $package_version." >&2
31
47
  exit 1
32
48
  fi
33
49
  - run: bun install --frozen-lockfile
34
50
  - run: bun run typecheck
35
51
  - run: bun run test
36
- - run: bun run build
37
- - run: npm publish --access public --provenance
52
+ - run: bun run build:npm
53
+ - name: Publish the platform packages
54
+ run: |
55
+ for directory in dist/npm/*/; do
56
+ name="$(node -p "require('./${directory}package.json').name")"
57
+ version="$(node -p "require('./${directory}package.json').version")"
58
+ # A rerun after a partial publish skips the versions already on npm.
59
+ if npm view "$name@$version" version >/dev/null 2>&1; then
60
+ echo "$name@$version is already published."
61
+ else
62
+ npm publish "$directory" --access public --provenance
63
+ fi
64
+ done
65
+ - name: Publish @drkmttr/adhere with the platform packages as optional dependencies
66
+ run: |
67
+ bun scripts/npm-packages.ts pin
68
+ npm publish --access public --provenance
@@ -55,8 +55,17 @@ jobs:
55
55
  - run: bun install --frozen-lockfile
56
56
  - run: bun run typecheck
57
57
  - run: bun run test
58
- - run: bun run build
58
+ - run: bun run build:npm
59
59
  - name: Create GitHub release
60
60
  run: bun run release -- --ci "${{ steps.release.outputs.version }}"
61
61
  env:
62
62
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63
+
64
+ publish:
65
+ needs: release
66
+ uses: ./.github/workflows/publish.yaml
67
+ permissions:
68
+ contents: read
69
+ id-token: write
70
+ with:
71
+ tag_name: ${{ github.ref_name }}
package/README.md CHANGED
@@ -9,13 +9,107 @@ 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
 
111
+ - `.adhere/config.ts`, the default, next to the rules
17
112
  - `adhere.config.ts`
18
- - `.adhere/config.ts`
19
113
  - `.adhere.config.ts`
20
114
 
21
115
  ```ts
@@ -40,33 +134,22 @@ 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.
46
-
47
- ### Rules as Markdown files
48
-
49
- A repo's own rules live in `.adhere/`, one `*.md` file per rule. The path
50
- without `.md` is the rule id, so `.adhere/data/brand-ports.md` is
51
- `data/brand-ports`. When that directory exists, it is read without any config.
52
- A repo that already has a `docs/` directory may prefer `docs/adhere/`, so the
53
- rules sit with the rest of its documentation; point `rules` at it:
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`.
54
143
 
55
- ```ts
56
- export default { presets: ["effect"], rules: "./docs/adhere" } satisfies Config;
57
- ```
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.
58
146
 
59
- A config is otherwise needed only to name presets or set thresholds.
147
+ ### Rules as Markdown files
60
148
 
61
- Nested `.adhere/` directories are also discovered. A rule in
62
- `packages/api/.adhere/data/brand-ports.md` has the same id,
63
- `data/brand-ports`, but applies only to files under `packages/api/`. Root
64
- `.adhere/` rules apply project-wide. If a nested rule has the same id as a root
65
- rule, the nearest containing `.adhere/` shadows the less-specific rule for that
66
- subtree; outside that subtree, the root rule still applies. There is no broader
67
- priority system: presets are global, project rules override preset rules with
68
- the same id, and nested project rules override less-specific project rules with
69
- the same id for files in their subtree.
149
+ A repo's own rules live in `.adhere/`, one `*.md` file per rule, next to the
150
+ config and the cache (neither is read as a rule). The path without `.md` is the
151
+ rule id, so `.adhere/data/brand-ports.md` is `data/brand-ports`. When that
152
+ directory exists, it is read without any config.
70
153
 
71
154
  A file is front matter, then a body. The first fenced code block in the body
72
155
  is the reference; prose around it renders on GitHub and is ignored. Without a
@@ -92,6 +175,30 @@ const Port = Schema.Int.pipe(
92
175
  validation refuses the run with its path in the message. `loadRules(directory)`
93
176
  from the package root does the same load for your own tooling.
94
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
+
95
202
  ### Presets
96
203
 
97
204
  A preset has the shape of a config without `presets`: `rules`, and optionally
@@ -100,7 +207,7 @@ A preset has the shape of a config without `presets`: `rules`, and optionally
100
207
  [effect-solutions](https://github.com/kitlangton/effect-solutions) docs and the
101
208
  [effect/platform](https://effect.website/docs/platform/introduction/) docs.
102
209
  Name it in the config, or on the command line with `--preset effect`, in which
103
- case `adhere.config.ts` is optional.
210
+ case the config file is optional.
104
211
 
105
212
  ### Precedence
106
213
 
@@ -109,40 +216,52 @@ order (a later preset wins), then the defaults `jev-latest` and `0.7`. A rule
109
216
  in `rules` replaces a preset rule with the same id. A rule's own `threshold`
110
217
  beats all of the above for that rule.
111
218
 
112
- ## Run
219
+ ## How a file is judged
113
220
 
114
- 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.
115
229
 
116
- ```sh
117
- bun install
118
- bun link # puts `adhere` on PATH
119
- adhere --preset effect
120
- ```
230
+ ## Cache
121
231
 
122
- `adhere` audits the working directory. When that directory contains `agents/`,
123
- `apps/`, or `packages/`, only those trees are read. Exit code 1 when there is
124
- at least one finding.
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.
125
239
 
126
- Initialize a new project with a config and example rules:
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.
127
242
 
128
- ```sh
129
- adhere init
130
- adhere init --force # overwrite the scaffold files if they already exist
131
- ```
243
+ ## Agent skill
132
244
 
133
- The command is safe to rerun: by default it reports existing files as skipped
134
- and does not clobber them.
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.
135
251
 
136
- Check only this project's configured rules for local textual contradictions:
252
+ ## Development
253
+
254
+ From a checkout, with Bun:
137
255
 
138
256
  ```sh
139
- adhere contradictions
257
+ bun install
258
+ bun link # puts `adhere` on PATH
259
+ adhere lint --preset effect
140
260
  ```
141
261
 
142
- It exits 1 and prints the conflicting rule files when overlapping rule scopes
143
- contain opposite textual directives for the same topic, such as "Use service
144
- classes for IO" and "Do not use service classes for IO". Same-id nested rules
145
- 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.
146
265
 
147
266
  ### Native executable
148
267
 
@@ -151,9 +270,16 @@ are treated as intentional shadowing rather than contradictions.
151
270
  [`--compile`](https://bun.sh/docs/bundler/executables) with
152
271
  `--asset ./presets`. It runs without Bun or `node_modules` on the target
153
272
  machine and still loads the repo's `config.ts` and Markdown rules from disk.
154
- For another platform, add `--target`, for example `bun-darwin-arm64`.
273
+ `bun run build:npm` compiles it for every published platform instead, each
274
+ into its package under `dist/npm/`
275
+ ([`scripts/npm-packages.ts`](./scripts/npm-packages.ts)).
276
+
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.
155
281
 
156
- ## Release
282
+ ### Release
157
283
 
158
284
  Releases are cut by CI from pushed version tags. From a clean, up-to-date
159
285
  `main`, push the release tag:
@@ -168,39 +294,17 @@ derives `0.3.0` from `v0.3.0`, and runs `bun run release -- --ci 0.3.0`.
168
294
  Release-it bumps `package.json`, commits `chore: release v0.3.0` back to
169
295
  `main`, skips npm publish, and creates the GitHub Release for the existing tag.
170
296
 
171
- Publishing stays in `.github/workflows/publish.yaml`: the published GitHub
172
- Release triggers the OIDC trusted-publisher job, which verifies that
173
- `package.json` matches the release tag and then runs
174
- `npm publish --access public --provenance`. No npm token is used.
175
-
176
- ## How a file is judged
177
-
178
- 1. One Jev request per file, with the file's numbered lines and every rule as
179
- the state, and one `noul` (yes/no probability) question per rule. Every
180
- question shares the state cost of the request.
181
- 2. A second request only when at least one rule's probability is above its
182
- threshold: one `choice` question per flagged rule over the file's non-blank
183
- lines, which yields the line to report. A file with more than 255 non-blank
184
- lines is located in two steps (a block of 20 lines, then a line inside it).
185
- Files longer than 5100 lines are skipped and counted in the summary.
186
-
187
- ## Cache
188
-
189
- Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
190
- per file. An entry stores the file's content hash and, per rule, the
191
- probability, the located line, and a fingerprint of the rule's text and model.
192
- A changed file re-judges every rule for that file. An edited rule re-judges
193
- only that rule. A lowered threshold locates cached judgments that are newly
194
- above it without judging again.
195
-
196
- `TYPESAFE_API_KEY` is read only when a request is about to be sent. A run
197
- where every file is cached needs no key and no network.
198
-
199
- ## Agent skill
200
-
201
- [`skills/adhere/SKILL.md`](./skills/adhere/SKILL.md) teaches an agent to
202
- gather a repo's conventions into rule files, configure adhere, and calibrate
203
- thresholds. Install it with the [skills](https://github.com/vercel-labs/skills)
204
- CLI: `skills add darkmatter/adhere`. The binary carries the same file:
205
- `adhere skill` prints it, so `adhere skill > .agents/skills/adhere/SKILL.md`
206
- works without a checkout.
297
+ Publishing stays in `.github/workflows/publish.yaml`: after release-it
298
+ finishes, `.github/workflows/release.yaml` calls the reusable publish workflow
299
+ directly, avoiding a chained GitHub Release event created by `GITHUB_TOKEN`.
300
+ The publish workflow verifies that `package.json` matches the release tag,
301
+ builds the platform packages, and publishes each of them before
302
+ `@drkmttr/adhere`, which it first lists them in as `optionalDependencies` at
303
+ the same version. Every publish is `npm publish --access public --provenance`.
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/bin/adhere.js ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `adhere` on PATH. Runs the executable compiled for this machine, which the
4
+ * install fetched as the optional dependency `@drkmttr/adhere-<platform>-<arch>`
5
+ * (see scripts/npm-packages.ts). A checkout has none installed, so there it
6
+ * runs src/main.ts with Bun instead.
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import { existsSync } from "node:fs";
10
+ import { createRequire } from "node:module";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const platform = `${process.platform}-${process.arch}`;
14
+ const platformPackage = `@drkmttr/adhere-${platform}`;
15
+ const executable = process.platform === "win32" ? "adhere.exe" : "adhere";
16
+ const args = process.argv.slice(2);
17
+
18
+ const resolveExecutable = () => {
19
+ try {
20
+ return createRequire(import.meta.url).resolve(`${platformPackage}/bin/${executable}`);
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ };
25
+
26
+ const run = (command, commandArgs) => {
27
+ const result = spawnSync(command, commandArgs, { stdio: "inherit" });
28
+ if (result.error) throw result.error;
29
+ process.exitCode = result.status ?? 1;
30
+ };
31
+
32
+ const prebuilt = resolveExecutable();
33
+ if (prebuilt) run(prebuilt, args);
34
+ else if (existsSync(new URL("../.git", import.meta.url)))
35
+ run("bun", [fileURLToPath(new URL("../src/main.ts", import.meta.url)), ...args]);
36
+ else {
37
+ console.error(
38
+ `adhere: ${platformPackage} is not installed, so there is no executable for ${platform}.` +
39
+ " It is an optional dependency of @drkmttr/adhere: reinstall without --omit=optional or --no-optional." +
40
+ " If it is still missing, adhere does not ship an executable for this platform.",
41
+ );
42
+ process.exitCode = 1;
43
+ }
package/bun.lock CHANGED
@@ -5,10 +5,10 @@
5
5
  "": {
6
6
  "name": "@drkmttr/adhere",
7
7
  "dependencies": {
8
- "@effect/platform-bun": "4.0.0-rc.112",
9
8
  "effect": "4.0.0-rc.112",
10
9
  },
11
10
  "devDependencies": {
11
+ "@effect/platform-bun": "4.0.0-rc.112",
12
12
  "@types/bun": "^1.4.2",
13
13
  "@types/node": "^24.13.6",
14
14
  "release-it": "^21.1.0",
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
- "description": "Lints a repository against reference code in adhere.config.ts, judged by Jev.",
5
+ "description": "Lints a repository against reference code in .adhere/, judged by Jev.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/darkmatter/adhere.git"
9
9
  },
10
10
  "bin": {
11
- "adhere": "src/main.ts"
11
+ "adhere": "bin/adhere.js"
12
12
  },
13
13
  "type": "module",
14
14
  "imports": {
@@ -26,13 +26,14 @@
26
26
  "test": "vp test",
27
27
  "typecheck": "tsc --noEmit && bun run lint",
28
28
  "build": "bun build --compile --minify --sourcemap src/main.ts --asset ./presets --outfile dist/adhere",
29
+ "build:npm": "bun scripts/npm-packages.ts build",
29
30
  "release": "release-it"
30
31
  },
31
32
  "dependencies": {
32
- "@effect/platform-bun": "4.0.0-rc.112",
33
33
  "effect": "4.0.0-rc.112"
34
34
  },
35
35
  "devDependencies": {
36
+ "@effect/platform-bun": "4.0.0-rc.112",
36
37
  "@types/bun": "^1.4.2",
37
38
  "@types/node": "^24.13.6",
38
39
  "release-it": "^21.1.0",
@@ -58,5 +59,12 @@
58
59
  "allowSameVersion": true,
59
60
  "publish": false
60
61
  }
62
+ },
63
+ "optionalDependencies": {
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"
61
69
  }
62
70
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The npm packages that carry the compiled executable, one per platform. Each
3
+ * is an optional dependency of `@drkmttr/adhere` limited by `os` and `cpu`, so
4
+ * an install fetches only its own machine's, and `bin/adhere.js` runs it.
5
+ *
6
+ * bun scripts/npm-packages.ts build compile every platform into dist/npm/
7
+ * bun scripts/npm-packages.ts pin list them in package.json at its version
8
+ *
9
+ * Only the publish job runs `pin`. The committed package.json leaves them out
10
+ * because a version is not on npm until that job has published it.
11
+ */
12
+ import packageJson from "../package.json";
13
+ import { $ } from "bun";
14
+ import { rm } from "node:fs/promises";
15
+
16
+ process.chdir(new URL("../", import.meta.url).pathname);
17
+
18
+ /** `os` and `cpu` are Node's `process.platform` and `process.arch`, which the launcher names the package from. */
19
+ const platforms = [
20
+ { os: "darwin", cpu: "arm64", target: "bun-darwin-arm64" },
21
+ { os: "darwin", cpu: "x64", target: "bun-darwin-x64" },
22
+ { os: "linux", cpu: "arm64", target: "bun-linux-arm64" },
23
+ { os: "linux", cpu: "x64", target: "bun-linux-x64" },
24
+ { os: "win32", cpu: "x64", target: "bun-windows-x64" },
25
+ ] as const;
26
+
27
+ type Platform = (typeof platforms)[number];
28
+
29
+ const packageName = ({ os, cpu }: Platform) => `${packageJson.name}-${os}-${cpu}`;
30
+
31
+ const writeJson = (path: string, value: unknown) =>
32
+ Bun.write(path, `${JSON.stringify(value, null, 2)}\n`);
33
+
34
+ const build = async () => {
35
+ for (const platform of platforms) {
36
+ const directory = `dist/npm/${platform.os}-${platform.cpu}`;
37
+ const executable = platform.os === "win32" ? "adhere.exe" : "adhere";
38
+ await rm(directory, { recursive: true, force: true });
39
+ await $`bun build --compile --minify --sourcemap --target=${platform.target} src/main.ts --asset ./presets --outfile ${directory}/bin/${executable}`;
40
+ await writeJson(`${directory}/package.json`, {
41
+ name: packageName(platform),
42
+ version: packageJson.version,
43
+ description: `The adhere executable for ${platform.os}-${platform.cpu}. Install ${packageJson.name}, which depends on it.`,
44
+ repository: packageJson.repository,
45
+ os: [platform.os],
46
+ cpu: [platform.cpu],
47
+ files: [`bin/${executable}`],
48
+ preferUnplugged: true,
49
+ publishConfig: packageJson.publishConfig,
50
+ });
51
+ }
52
+ };
53
+
54
+ const pin = () =>
55
+ writeJson("package.json", {
56
+ ...packageJson,
57
+ optionalDependencies: Object.fromEntries(
58
+ platforms.map((platform) => [packageName(platform), packageJson.version]),
59
+ ),
60
+ });
61
+
62
+ const command = Bun.argv[2];
63
+ if (command === "build") await build();
64
+ else if (command === "pin") await pin();
65
+ else {
66
+ console.error("usage: bun scripts/npm-packages.ts build|pin");
67
+ process.exit(2);
68
+ }
@@ -38,15 +38,17 @@ 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
 
45
45
  ## 3. Write each rule as a file
46
46
 
47
- Default location `.adhere/<topic>/<slug>.md`. If the repo already has `docs/`,
48
- use `docs/adhere/<topic>/<slug>.md` and set `rules: "./docs/adhere"` in the
49
- config. The path without `.md` is the rule id.
47
+ Default location `.adhere/<topic>/<slug>.md`, next to the config and the
48
+ cache. Use `docs/adhere/<topic>/<slug>.md` with `rules: "./docs/adhere"` in the
49
+ config only when the user wants rules kept with the repo's docs; rules read
50
+ that way apply project-wide, and nested `.adhere/` directories are not read.
51
+ The path without `.md` is the rule id.
50
52
 
51
53
  ````md
52
54
  ---
@@ -73,10 +75,10 @@ Rules for the rule:
73
75
 
74
76
  ## 4. Configure
75
77
 
76
- Pick one config location: `adhere.config.ts`, `.adhere/config.ts`, or
77
- `.adhere.config.ts`. A config is optional when `.adhere/` exists or a preset is
78
- passed on the command line. Use a type-only import so the file also loads
79
- under the native binary:
78
+ Put the config at `.adhere/config.ts`. `adhere.config.ts` and
79
+ `.adhere.config.ts` also load; keep one. A config is optional when `.adhere/`
80
+ exists or a preset is passed on the command line. Use a type-only import so
81
+ the file also loads under the native binary:
80
82
 
81
83
  ```ts
82
84
  import type { Config } from "@drkmttr/adhere";
@@ -93,8 +95,9 @@ uncached files; in this org it comes from SOPS via `just shell`.
93
95
  ## 5. Run and calibrate
94
96
 
95
97
  ```sh
96
- adhere # or: adhere --preset effect
97
- 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
98
101
  ```
99
102
 
100
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,16 +39,9 @@ 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
- Flag.withDescription("Add a built-in rule set. With a preset, adhere.config.ts is optional."),
44
+ Flag.withDescription("Add a built-in rule set. With a preset, .adhere/config.ts is optional."),
52
45
  );
53
46
 
54
47
  const threshold = Flag.float("threshold").pipe(
@@ -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;
@@ -86,89 +81,70 @@ const audit = Command.make("adhere", { preset, threshold }, () =>
86
81
  }),
87
82
  ).pipe(
88
83
  Command.withDescription(
89
- "Audit the working directory against reference code from adhere.config.ts or a preset, judged by Jev.",
84
+ "Audit the working directory against reference code from .adhere/config.ts or a preset, judged by Jev.",
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
@@ -32,6 +32,12 @@ export const AdhereConfig = Schema.Struct({
32
32
  /** Where a repo keeps its rule files by default. The cache lives under it. */
33
33
  export const ADHERE_DIRECTORY = ".adhere";
34
34
  export const CACHE_DIRECTORY = `${ADHERE_DIRECTORY}/cache`;
35
+ /** Where a config may live, relative to the working directory. Exactly one may exist. */
36
+ export const CONFIG_FILES = [
37
+ `${ADHERE_DIRECTORY}/config.ts`,
38
+ "adhere.config.ts",
39
+ ".adhere.config.ts",
40
+ ] as const;
35
41
  export interface AdhereConfig extends Schema.Schema.Type<typeof AdhereConfig> {}
36
42
 
37
43
  /** The shape a config file default-exports. */
package/src/init.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { access, mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, relative } from "node:path";
3
+ import { CONFIG_FILES } from "#config.ts";
3
4
  import { Effect } from "effect";
4
5
 
5
6
  export interface InitResult {
@@ -44,8 +45,10 @@ export const loadCustomerProfile = (customerId: CustomerId) =>
44
45
  \`\`\`
45
46
  `;
46
47
 
48
+ const CONFIG_PATH = CONFIG_FILES[0];
49
+
47
50
  const files = [
48
- ["adhere.config.ts", CONFIG],
51
+ [CONFIG_PATH, CONFIG],
49
52
  [".adhere/style/prefer-small-files.md", SMALL_FILES],
50
53
  [".adhere/style/name-domain-actions.md", NAME_EFFECTS],
51
54
  ] as const;
@@ -56,6 +59,15 @@ const exists = (path: string): Promise<boolean> =>
56
59
  () => false,
57
60
  );
58
61
 
62
+ /** A config at another accepted path. Scaffolding a second one would refuse every run. */
63
+ const otherConfig = (root: string) =>
64
+ Effect.promise(async () => {
65
+ for (const file of CONFIG_FILES) {
66
+ if (file !== CONFIG_PATH && (await exists(join(root, file)))) return file;
67
+ }
68
+ return undefined;
69
+ });
70
+
59
71
  const writeScaffoldFile = (root: string, path: string, contents: string, options: InitOptions) =>
60
72
  Effect.tryPromise({
61
73
  try: async () => {
@@ -79,7 +91,12 @@ export const initProject = (
79
91
  Effect.gen(function* () {
80
92
  const created: Array<string> = [];
81
93
  const skipped: Array<string> = [];
94
+ const existing = yield* otherConfig(root);
82
95
  for (const [path, contents] of files) {
96
+ if (path === CONFIG_PATH && existing !== undefined) {
97
+ skipped.push(existing);
98
+ continue;
99
+ }
83
100
  const status = yield* writeScaffoldFile(root, path, contents, options);
84
101
  if (status === "created") {
85
102
  created.push(relative(root, join(root, path)));
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,
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ADHERE_DIRECTORY,
3
3
  type AdhereConfig as Decoded,
4
+ CONFIG_FILES,
4
5
  ConfigUnavailable,
5
6
  decodeConfig,
6
7
  type Loaded,
@@ -20,9 +21,6 @@ import {
20
21
  } from "#rules.ts";
21
22
  import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
22
23
 
23
- /** Where a config may live, relative to the working directory. Exactly one may exist. */
24
- export const CONFIG_FILES = ["adhere.config.ts", ".adhere/config.ts", ".adhere.config.ts"] as const;
25
-
26
24
  export class AdhereConfig extends Context.Service<
27
25
  AdhereConfig,
28
26
  ResolvedConfig
@@ -1,4 +1,4 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { Crypto, Effect, FileSystem, Layer, Path, Record } from "effect";
@@ -433,7 +433,7 @@ describe("init", () => {
433
433
  it("scaffolds config and example rules without clobbering existing files", async () => {
434
434
  const root = join(tmpdir(), `adhere-init-${Date.now()}`);
435
435
  await Effect.runPromise(initProject(root));
436
- const config = await readFile(join(root, "adhere.config.ts"), "utf8");
436
+ const config = await readFile(join(root, ".adhere", "config.ts"), "utf8");
437
437
  const rule = await readFile(join(root, ".adhere", "style", "prefer-small-files.md"), "utf8");
438
438
 
439
439
  expect(config).toContain("satisfies Config");
@@ -441,9 +441,21 @@ describe("init", () => {
441
441
 
442
442
  const second = await Effect.runPromise(initProject(root));
443
443
  expect(second.created).toEqual([]);
444
- expect(second.skipped).toContain("adhere.config.ts");
444
+ expect(second.skipped).toContain(".adhere/config.ts");
445
445
  expect(second.skipped).toContain(".adhere/style/prefer-small-files.md");
446
446
  });
447
+
448
+ it("keeps a config at another accepted path instead of adding a second one", async () => {
449
+ const root = join(tmpdir(), `adhere-init-existing-${Date.now()}`);
450
+ await mkdir(root, { recursive: true });
451
+ await writeFile(join(root, "adhere.config.ts"), "export default {};\n", "utf8");
452
+
453
+ const result = await Effect.runPromise(initProject(root, { force: true }));
454
+
455
+ expect(result.skipped).toContain("adhere.config.ts");
456
+ expect(result.created).not.toContain(".adhere/config.ts");
457
+ await expect(access(join(root, ".adhere", "config.ts"))).rejects.toThrow();
458
+ });
447
459
  });
448
460
 
449
461
  describe("pipeline", () => {
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
  });
package/tsconfig.json CHANGED
@@ -21,6 +21,6 @@
21
21
  "#*": ["./src/*"]
22
22
  }
23
23
  },
24
- "include": ["src/**/*.ts", "test/**/*.ts", "adhere.config.ts"],
24
+ "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", ".adhere/config.ts"],
25
25
  "exclude": ["dist", "node_modules"]
26
26
  }