@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.
- package/{adhere.config.ts → .adhere/config.ts} +1 -1
- package/.github/workflows/ci.yaml +9 -1
- package/.github/workflows/publish.yaml +37 -6
- package/.github/workflows/release.yaml +10 -1
- package/README.md +193 -89
- package/bin/adhere.js +43 -0
- package/bun.lock +1 -1
- package/package.json +12 -4
- package/scripts/npm-packages.ts +68 -0
- package/skills/adhere/SKILL.md +13 -10
- package/src/cli.ts +63 -87
- package/src/config.ts +6 -0
- package/src/init.ts +18 -1
- package/src/main.ts +13 -11
- package/src/services/AdhereConfig.ts +1 -3
- package/test/audit.test.ts +15 -3
- package/test/cli.test.ts +28 -0
- package/tsconfig.json +1 -1
- /package/.adhere/{adhere → rules}/refusals-name-the-file.md +0 -0
|
@@ -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
|
-
|
|
5
|
-
|
|
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="${
|
|
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 $
|
|
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
|
-
-
|
|
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
|
|
15
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
147
|
+
### Rules as Markdown files
|
|
60
148
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
##
|
|
219
|
+
## How a file is judged
|
|
113
220
|
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
bun install
|
|
118
|
-
bun link # puts `adhere` on PATH
|
|
119
|
-
adhere --preset effect
|
|
120
|
-
```
|
|
230
|
+
## Cache
|
|
121
231
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
adhere init
|
|
130
|
-
adhere init --force # overwrite the scaffold files if they already exist
|
|
131
|
-
```
|
|
243
|
+
## Agent skill
|
|
132
244
|
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
From a checkout, with Bun:
|
|
137
255
|
|
|
138
256
|
```sh
|
|
139
|
-
|
|
257
|
+
bun install
|
|
258
|
+
bun link # puts `adhere` on PATH
|
|
259
|
+
adhere lint --preset effect
|
|
140
260
|
```
|
|
141
261
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Lints a repository against reference code in adhere
|
|
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": "
|
|
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
|
+
}
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -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
|
|
48
|
-
|
|
49
|
-
config
|
|
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
|
-
|
|
77
|
-
`.adhere.config.ts
|
|
78
|
-
passed on the command line. Use a type-only import so
|
|
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
|
|
97
|
-
adhere
|
|
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
|
|
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
|
|
69
|
-
export const
|
|
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
|
|
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:
|
|
88
|
+
presets: chosenPresets(input.preset),
|
|
94
89
|
threshold: Option.getOrUndefined(input.threshold),
|
|
95
90
|
}),
|
|
96
91
|
),
|
|
97
92
|
);
|
|
98
93
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
yield*
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
174
|
-
export const
|
|
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
|
-
[
|
|
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 {
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/test/audit.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
File without changes
|