@drkmttr/adhere 0.9.4 → 0.10.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.
Files changed (87) hide show
  1. package/README.md +257 -65
  2. package/package.json +6 -6
  3. package/presets/alchemy/apis/match-remote-errors-by-tag.md +6 -2
  4. package/presets/alchemy/apis/schemas-at-trust-boundaries.md +6 -2
  5. package/presets/alchemy/data/commit-migrations-then-deploy.md +6 -2
  6. package/presets/alchemy/data/hyperdrive-direct-origin.md +6 -2
  7. package/presets/alchemy/data/migrations-use-direct-url.md +6 -2
  8. package/presets/alchemy/data/never-disable-tls-verification.md +6 -2
  9. package/presets/alchemy/data/parameterized-sql.md +6 -2
  10. package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
  11. package/presets/alchemy/data/single-migration-owner.md +6 -2
  12. package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
  13. package/presets/alchemy/durable/io-inside-steps.md +6 -2
  14. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
  15. package/presets/alchemy/durable/stable-step-names.md +6 -2
  16. package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
  17. package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
  18. package/presets/alchemy/providers/idempotent-delete.md +8 -3
  19. package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
  20. package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
  21. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
  22. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
  23. package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
  24. package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
  25. package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
  26. package/presets/alchemy/resources/stable-logical-ids.md +6 -2
  27. package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
  28. package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
  29. package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
  30. package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
  31. package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
  32. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
  33. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
  34. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
  35. package/presets/alchemy/secrets/never-logged-returned-or-output.md +6 -2
  36. package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
  37. package/presets/alchemy/secrets/redacted-until-use.md +6 -2
  38. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
  39. package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
  40. package/presets/alchemy/security/github-secret-not-variable.md +6 -2
  41. package/presets/alchemy/security/identity-from-verified-session.md +6 -2
  42. package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
  43. package/presets/alchemy/security/least-privilege-bindings.md +6 -2
  44. package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
  45. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
  46. package/presets/effect/basics/external-calls-are-resilient.md +6 -2
  47. package/presets/effect/basics/gen-for-sequencing.md +6 -2
  48. package/presets/effect/basics/instrument-with-pipe.md +6 -2
  49. package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
  50. package/presets/effect/config/secrets-are-redacted.md +6 -2
  51. package/presets/effect/config/tests-provide-values-directly.md +7 -2
  52. package/presets/effect/config/validate-with-schema.md +6 -2
  53. package/presets/effect/data/brand-meaningful-primitives.md +6 -2
  54. package/presets/effect/data/variants-are-tagged-unions.md +6 -2
  55. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  56. package/presets/effect/errors/defects-for-bugs.md +6 -2
  57. package/presets/effect/errors/wrap-external-errors.md +6 -2
  58. package/presets/effect/services/dependencies-through-layers.md +6 -2
  59. package/presets/effect/services/fresh-layer-per-test.md +7 -2
  60. package/presets/effect/services/memoize-parameterized-layers.md +6 -2
  61. package/presets/effect/services/no-mutable-state.md +6 -2
  62. package/presets/effect/services/operations-have-no-requirements.md +6 -2
  63. package/presets/effect/services/test-layers-are-in-memory.md +7 -2
  64. package/presets/effect/testing/test-clock-for-time.md +7 -2
  65. package/skills/adhere/SKILL.md +40 -12
  66. package/skills/adhere-fix/SKILL.md +174 -0
  67. package/src/cli.ts +51 -14
  68. package/src/comments.ts +36 -0
  69. package/src/config.ts +98 -14
  70. package/src/contradictions.ts +13 -3
  71. package/src/excerpt.ts +11 -1
  72. package/src/index.ts +1 -1
  73. package/src/init.ts +119 -12
  74. package/src/main.ts +0 -1
  75. package/src/markdown.ts +77 -14
  76. package/src/mechanical.ts +2 -2
  77. package/src/models/Audit.ts +2 -0
  78. package/src/presets.ts +9 -3
  79. package/src/pricing.ts +14 -0
  80. package/src/rules.ts +43 -1
  81. package/src/services/AdhereConfig.ts +40 -14
  82. package/src/services/Jev.ts +17 -0
  83. package/src/services/SourceWalker.ts +32 -10
  84. package/src/suppress.ts +113 -0
  85. package/src/wording.ts +2 -2
  86. package/src/workflows/audit.ts +137 -27
  87. package/src/workflows/format.ts +62 -15
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # adhere
1
+ <div align="center">
2
+ <h1>adhere</h1>
3
+ </div>
2
4
 
3
5
  A linter for rules a normal linter cannot check. A rule is a one-sentence
4
6
  description in RFC 2119's words, like "business logic must live in services",
@@ -8,8 +10,11 @@ model) whether the file breaks each rule, gets a calibrated probability per
8
10
  rule, and reports the ones above a threshold with the line Jev points at. The
9
11
  report uses the same frame as `vp lint`.
10
12
 
11
- Deterministic rules (substring matches, type checks) belong in a normal linter;
12
- `adhere validate` points out a rule that looks like one.
13
+ Rules a normal linter can check exactly, such as a banned import or a type
14
+ error, belong in that linter. While it lints, adhere asks Jev whether a normal
15
+ linter could check each of your rules, and [`adhere validate`](#validate) lists
16
+ rules that it thinks belong in a regular linter, and also detects any
17
+ contradictions in your rules.
13
18
 
14
19
  A preset for effect is included which can be run without setup:
15
20
 
@@ -27,7 +32,7 @@ To pin the version in a repo, for CI or scripts, add it as a dev dependency
27
32
  and run `npx adhere`.
28
33
 
29
34
  `adhere` is a prebuilt executable for macOS and Linux on arm64 and x64 and for
30
- Windows on x64. .
35
+ Windows on x64.
31
36
 
32
37
  adhere sends each file it judges to Jev at `api.typesafe.ai`, authenticated
33
38
  with a TypeSafe AI API key: the one `adhere login` saved, or
@@ -36,7 +41,7 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
36
41
  ## Quick start
37
42
 
38
43
  ```sh
39
- adhere init # .adhere/config.ts and two example rules
44
+ adhere init # .adhere/config.ts, two example rules, and the dependency
40
45
  adhere login # save your TypeSafe AI API key, once
41
46
  adhere validate # Checks your rules' wording, and contradictions using Jev
42
47
  adhere lint # audit the working directory
@@ -66,16 +71,21 @@ Found 1 error.
66
71
  42 files, 3 judged, 39 cached.
67
72
  ```
68
73
 
69
- The header is the rule id, Jev's probability, and the rule's description.
74
+ The header is the rule id, Jev's probability, and the rule's description. A
75
+ preset's rule has the preset's name first, as in
76
+ `effect/basics/external-calls-are-resilient`, so a report that mixes presets
77
+ with a repo's own rules says where each came from.
70
78
  Under it is the line Jev points at, underlined, with the code around it: the
71
79
  largest statement around the line that is 30 lines or fewer, usually the whole
72
80
  function, and up to 3 lines of whole statements on either side. The
73
81
  hint is the rule's code that must be written; a rule with only code that must
74
- never be written shows that code, labeled `never:`, instead. On a terminal, the report is in color and the code
75
- in it is highlighted. The exit code is 0 when nothing is reported and 1 when
76
- something is. It is also 1 when the run refuses, for example on an invalid
77
- config or rule file, a missing API key, or an unknown command or flag; a
78
- refusal prints its reason.
82
+ never be written shows that code, labeled `never:`, instead. A rule whose
83
+ level is `warning` reports its findings with `⚠` in place of `×`, in amber. On
84
+ a terminal, the report is in color and the code in it is highlighted. The exit
85
+ code is 1 when an error is reported, and 0 when nothing is, or only warnings;
86
+ `--deny-warnings` fails the run on warnings too. It is also 1 when the run
87
+ refuses, for example on an invalid config or rule file, a missing API key, or
88
+ an unknown command or flag; a refusal prints its reason.
79
89
 
80
90
  ## Usage
81
91
 
@@ -87,6 +97,7 @@ adhere lint --yes # send the requests without asking first
87
97
  adhere lint --limit 500 # judge at most 500 checks; the rest wait for the next run
88
98
  adhere lint --rpm 30 # send at most 30 requests a minute
89
99
  adhere lint --filter 'src/**' # read only the files a glob matches
100
+ adhere lint --deny-warnings # fail on warnings as well as errors
90
101
  adhere lint --log-level debug # log each request to Jev on stderr
91
102
  adhere validate # check the rules' wording; ask Jev whether any contradict
92
103
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
@@ -99,6 +110,10 @@ Bare `adhere` prints the help, which lists the commands, and
99
110
  `adhere <command> --help` lists a command's flags. `adhere --completions <shell>`
100
111
  prints a completion script.
101
112
 
113
+ ### Demo
114
+
115
+ <a href="https://asciinema.org/a/1266511" target="_blank"><img width="400" src="https://asciinema.org/a/1266511.svg" /></a>
116
+
102
117
  ### Before and during a run
103
118
 
104
119
  Until it knows what judging takes, a status line on stderr says what `lint` is
@@ -111,14 +126,17 @@ what judging takes:
111
126
  ```text
112
127
  200 files and 14 rules: 2800 checks, 1400 cached.
113
128
  Judging the other 1400 takes 200 requests to Jev, plus 1 or more for each file with a finding.
114
- ? Send 200 requests to Jev? › (Y/n)
129
+ Those carry about 1.9 million input tokens: about $0.08 at $0.042 per million, and more for locating findings.
130
+ ? Send 200 requests to Jev, about $0.08? › (Y/n)
115
131
  ```
116
132
 
133
+ The cost is adhere's estimate of the input tokens times the model's price.
134
+ Jev charges only for input tokens: $0.042 a million for `jev-latest`, per
135
+ [TypeSafe AI's models page](https://docs.typesafe.ai/models) in September 2026.
136
+ For a model adhere has no price for, the plan gives the tokens alone.
137
+
117
138
  It asks only with a terminal on stdin and stdout, and never when the cache
118
- answers every check; `--yes` sends without asking. While it runs, a counter on
119
- stderr shows the files done, the requests sent, and the findings so far, and
120
- the report goes to stdout. A request Jev refuses stops the run with the file,
121
- the HTTP status, and Jev's reason. Files finished before it are cached, so
139
+ answers every check; `--yes` sends without asking. Files finished before it are cached, so
122
140
  running again continues from there.
123
141
 
124
142
  The firewall in front of Jev's API can refuse a request whose code reads to
@@ -136,13 +154,60 @@ next run with the same limit picks up where this one stopped. `--limit 0` shows
136
154
  the plan and judges nothing. `--rpm <requests>` sends at most that many requests
137
155
  to Jev a minute, evenly spaced, retries included, and the plan says about how
138
156
  long they take. `--filter <glob>` reads only the files whose path from the
139
- working directory matches, such as `src/**` or `**/*.service.ts`; repeat it for
140
- more, and start a pattern with `!` to leave out what it matches. Together:
157
+ working directory matches, such as `src/**` or `**/*.service.ts` (if using globs,
158
+ wrap with single quotes to avoid expansion); repeat it for more, and start a
159
+ pattern with `!` to leave out what it matches. Together:
141
160
 
142
161
  ```sh
143
162
  adhere lint --filter 'packages/api/**' --filter '!**/generated/**' --limit 200 --rpm 30
144
163
  ```
145
164
 
165
+ ### Comments
166
+
167
+ Jev reads a file's code, not its comments. A comment changes nothing the code
168
+ does, so it should not change the report, yet Jev takes what comments claim at
169
+ their word: in a study of one alchemy rule, comments saying a delete succeeds
170
+ on a missing resource hid 8 of its 9 real violations, and taking comments out
171
+ found all nine. adhere takes every comment out before it hashes or sends a
172
+ file, blanking it so every line keeps its number; the report's excerpts still
173
+ show them.
174
+
175
+ A comment that says `@adhere` anywhere in it is a note for Jev, and stays.
176
+ Write one for a fact the code relies on that the file cannot show, once you
177
+ have checked it, with how you know:
178
+
179
+ ```ts
180
+ /**
181
+ * Deletes the activity. @adhere DeleteActivity succeeds on a missing
182
+ * activity, so no not-found error needs catching; probed 2026-09-25.
183
+ */
184
+ ```
185
+
186
+ A note informs Jev; it suppresses nothing, and Jev still judges the code
187
+ around it. `includeComments: true` in the config sends every comment, for a
188
+ project whose rules are about comments, such as doc comments on exports.
189
+
190
+ ### Suppressing a finding
191
+
192
+ A finding Jev got wrong is suppressed in the code, with a comment saying why:
193
+
194
+ ```ts
195
+ // adhere-ignore alchemy/providers/idempotent-delete -- DeleteActivity succeeds on a missing activity; probed 2026-09-25
196
+ delete: Effect.fn(function* ({ output }) {
197
+ yield* sfn.deleteActivity({ activityArn: output.activityArn });
198
+ }),
199
+ ```
200
+
201
+ On a line of its own, `adhere-ignore` covers the statement that starts on the
202
+ next line of code: here the whole handler, wherever in it Jev points, since the
203
+ line it points at can move between runs. At the end of a line of code, it
204
+ covers the statement that starts on that line. `adhere-ignore-file`, anywhere
205
+ in a file, covers the whole file, and the rules it names are not judged there
206
+ at all. A comment names rules as a report does, a preset's with its preset
207
+ first, separated by commas; what follows `--` is the reason, for whoever
208
+ reads the code next. Jev never reads these comments, even with
209
+ `includeComments: true`, and the summary counts the findings they suppress.
210
+
146
211
  ### Logging a run
147
212
 
148
213
  `--log-level debug` logs a run's work on stderr: the config it loaded, how many
@@ -161,21 +226,47 @@ adhere lint --log-level debug 2> adhere.log
161
226
 
162
227
  ### What gets read
163
228
 
164
- `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
165
- `.test.ts`, and config files. When the working directory contains `agents/`,
166
- `apps/`, or `packages/`, only those trees are read. Below the working
167
- directory, anything under `node_modules/`, `dist/`, `coverage/`, `vendor/`,
168
- `e2e/`, `references/`, `.adhere/`, `.agents/`, `.claude/`, `.direnv/`,
169
- `.alchemy/`, or `.vite/` is skipped; the directories above it do not count.
170
- `.tsx` files are not read, and `.gitignore` is not consulted.
229
+ `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`
230
+ and config files. When the working directory contains `agents/`, `apps/`, or
231
+ `packages/`, only those trees are read. Below the working directory, anything
232
+ under `node_modules/`, `dist/`, `coverage/`, `vendor/`, `e2e/`, `references/`,
233
+ `.adhere/`, `.agents/`, `.claude/`, `.direnv/`, `.alchemy/`, or `.vite/` is
234
+ skipped; the directories above it do not count. `.tsx` files are not read, and
235
+ `.gitignore` is not consulted.
236
+
237
+ Tests are judged only by rules about tests. A test is a `.test.ts` or
238
+ `.spec.ts` file, or any file under a `test/`, `tests/`, `__tests__/`, or
239
+ `fixtures/` directory, and only a rule whose front matter says `tests` judges
240
+ one (see [Rules as Markdown files](#rules-as-markdown-files)). On alchemy, 38%
241
+ of the findings from rules not about tests were in test helpers and fixtures.
242
+
243
+ A config's `exclude` lists globs, relative to the working directory, of files
244
+ no rule judges, such as generated code. It leaves them out of every run, as
245
+ `--filter '!<glob>'` leaves them out of one:
246
+
247
+ ```ts
248
+ export default defineConfig({ exclude: ["**/generated/**"] });
249
+ ```
171
250
 
172
251
  ### Init
173
252
 
174
- `adhere init` writes `.adhere/config.ts` and two example rules. It is safe to
175
- rerun: by default it reports existing files as skipped and does not clobber
253
+ `adhere init` writes `.adhere/config.ts`, the `.adhere/tsconfig.json` that gives
254
+ the editor its types (see [Config](#config)), and two example rules. It is safe
255
+ to rerun: by default it reports existing files as skipped and does not clobber
176
256
  them. `--force` overwrites them. A config already at another accepted path is
177
257
  kept, and no second one is added, even with `--force`.
178
258
 
259
+ When there is a `package.json`, init also adds `@drkmttr/adhere` to its
260
+ `devDependencies`, so the config's `defineConfig` has its types in the editor. It runs
261
+ `<manager> add -D @drkmttr/adhere` with the package manager whose lockfile
262
+ (`bun.lock`, `bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`)
263
+ sits beside the `package.json`, or npm when there is none. With pnpm it adds
264
+ `--ignore-workspace-root-check`, so the install also works at a workspace root,
265
+ and `--config.strict-dep-builds=false`, so pnpm 11 skips the build script of
266
+ msgpackr-extract, an optional native add-on that comes with `effect` and that
267
+ adhere never uses, with a warning instead of failing.
268
+ It skips the install when `package.json` already lists adhere.
269
+
179
270
  ### Validate
180
271
 
181
272
  `adhere validate` loads the config, rule files, and presets the way `lint`
@@ -241,12 +332,14 @@ repo being audited, at one of these paths (keep one):
241
332
  - `.adhere.config.ts`
242
333
 
243
334
  ```ts
244
- import type { Config } from "@drkmttr/adhere";
335
+ import { defineConfig } from "@drkmttr/adhere";
245
336
 
246
- export default {
337
+ export default defineConfig({
247
338
  model: "jev-latest", // optional, default "jev-latest"
248
339
  threshold: 0.8, // optional, default 0.8
249
340
  presets: ["effect"], // optional, built-in rule sets
341
+ exclude: ["**/generated/**"], // optional, files no rule judges
342
+ overrides: { "effect/basics/instrument-with-pipe": "off" }, // optional, see Presets
250
343
  rules: {
251
344
  "data/brand-meaningful-primitives": {
252
345
  description:
@@ -259,16 +352,24 @@ type UserId = typeof UserId.Type
259
352
  threshold: 0.8, // optional per-rule override
260
353
  },
261
354
  },
262
- } satisfies Config;
355
+ });
263
356
  ```
264
357
 
265
358
  The default export is decoded with Effect `Schema`. An invalid shape refuses
266
- the run. The `import type` is erased at runtime. `defineConfig({...})` from
267
- `@drkmttr/adhere` does the same thing as `satisfies Config`, and the executable
268
- supplies it whether or not the repo has the package installed; the editor's
269
- types come from the package as a dev dependency. A config can import other
270
- files by relative path, but no packages besides `@drkmttr/adhere`: the
271
- executable does not resolve `node_modules`.
359
+ the run. `defineConfig` types the config for the editor and returns it as it
360
+ is; `satisfies Config`, with `import type { Config }`, does the same. The
361
+ executable supplies `@drkmttr/adhere` to the config whether or not the repo has
362
+ the package installed, and the editor's types come from the package, which
363
+ `adhere init` adds as a dev dependency. A config can import other files by
364
+ relative path, but no packages besides `@drkmttr/adhere`: the executable does
365
+ not resolve `node_modules`.
366
+
367
+ A tsconfig's globs skip dot directories, so the editor opens
368
+ `.adhere/config.ts` outside any project, where it cannot resolve the package's
369
+ types: they are reached only by `bundler`, `node16`, or `nodenext` resolution.
370
+ `adhere init` writes `.adhere/tsconfig.json`, a project for the config alone
371
+ with `bundler` resolution. For a config written by hand, add that file, or
372
+ include `.adhere/config.ts` in a tsconfig that resolves the same way.
272
373
 
273
374
  `rules` in the config, inline as above or as a directory (below), replaces the
274
375
  `.adhere/` rule files: none of them, root or nested, is read then.
@@ -280,12 +381,19 @@ config and the cache (neither is read as a rule). The path without `.md` is the
280
381
  rule id, so `.adhere/data/brand-ports.md` is `data/brand-ports`. When that
281
382
  directory exists, it is read without any config.
282
383
 
283
- A file is front matter, then a body. The body's code goes in fences tagged,
284
- after the language, with RFC 2119's words: `must` for code that must be
285
- written, and `never` for code that must never be, which is what a violation
286
- looks like. Prose around them renders on GitHub and is ignored. An untagged
287
- fence is code that must be written, and without a fence the whole body is.
288
- `avoid`, the tag before 0.7, reads as `never`.
384
+ A file is front matter, then a body. The body's code goes in fences under
385
+ headings of RFC 2119's words: `## Must` for code that must be written, and
386
+ `## Never` for code that must never be, which is what a violation looks like.
387
+ A heading names the code in its section, which runs to the next heading at its
388
+ level or higher, so a deeper heading such as `### A bare number` stays inside
389
+ it. Only a heading that is the word alone names code, in any case and with or
390
+ without a colon: `## Never:` does, `## Never do this` does not. Prose around
391
+ the code renders on GitHub and is ignored.
392
+
393
+ A fence can instead name its code after its language, as in `ts never`, which
394
+ GitHub does not show; a fence's own word wins over its heading's. An untagged
395
+ fence outside those sections is code that must be written, and without a fence
396
+ the whole body is. `avoid`, the tag before 0.7, reads as `never`.
289
397
 
290
398
  ````md
291
399
  ---
@@ -295,22 +403,31 @@ threshold: 0.8
295
403
 
296
404
  Why: a bare `number` accepts 70000 and -1.
297
405
 
298
- ```ts must
406
+ ## Must
407
+
408
+ ```ts
299
409
  const Port = Schema.Int.pipe(
300
410
  Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
301
411
  Schema.brand("Port"),
302
412
  );
303
413
  ```
304
414
 
305
- ```ts never
415
+ ## Never
416
+
417
+ ```ts
306
418
  const port: number = Number(process.env.PORT);
307
419
  ```
308
420
  ````
309
421
 
310
422
  `description` is required, with code under `must`, `never`, or both.
311
- `threshold` is optional. A file that fails validation refuses the run with its
312
- path in the message. `loadRules(directory)` from the package root does the same
313
- load for your own tooling.
423
+ `threshold` is optional, and so is `tests`, for a rule that judges tests, which
424
+ rules otherwise skip: `tests: only` for a rule about tests, which judges
425
+ nothing else, and `tests: include` for one that holds in tests as well. So is
426
+ `level`: `level: warning` for a nit, or for a rule that tends to flag code
427
+ wrongly, reports its findings as warnings, which do not fail the run. A rule
428
+ inline in a config takes the same `tests` and `level`. A file that fails validation refuses
429
+ the run with its path in the message. `loadRules(directory)` from the package
430
+ root does the same load for your own tooling.
314
431
 
315
432
  Jev reads the code under the same words, and is asked whether the file
316
433
  diverges from the pattern `must` shows, with `never` as an example of
@@ -323,7 +440,7 @@ dropped. Neither goes in the other's place: code that must never be written,
323
440
  under `must`, reads to Jev as the pattern to follow.
324
441
 
325
442
  A rule that is a guideline rather than a requirement says "should" instead:
326
- in its description, and in fences tagged `should` and `should not` (`should`
443
+ in its description, and in `## Should` and `## Should not` headings (`should`
327
444
  and `shouldNot` inline in a config). Jev then reads the code under `should`
328
445
  and `should_not`. A rule is one or the other, so it cannot mix `must` or
329
446
  `never` with `should` or `should not`. In a config, `reference` and `avoid`,
@@ -349,7 +466,7 @@ cost is visibility: a dot directory is hidden from `ls`, and from `rg` without
349
466
  documentation can point `rules` at `docs/adhere/`:
350
467
 
351
468
  ```ts
352
- export default { presets: ["effect"], rules: "./docs/adhere" } satisfies Config;
469
+ export default defineConfig({ presets: ["effect"], rules: "./docs/adhere" });
353
470
  ```
354
471
 
355
472
  Rules read through `rules` apply project-wide.
@@ -360,7 +477,7 @@ We've evaluated different ways of giving Jev a rule, to catch the most
360
477
  violations with the fewest false positives. In general:
361
478
 
362
479
  - Say "must" for what code must do and "never" for what it must not, in the
363
- description and as the fence tags.
480
+ description and in the headings over the code.
364
481
  - Give one example of each: one `must` block and one `never` block. Three of
365
482
  each did no better, and several of one kind alone did worse.
366
483
  - For a guideline rather than a requirement, say "should" and "should not"
@@ -377,7 +494,8 @@ A preset has the shape of a config without `presets`: `rules`, and optionally
377
494
  `model` and `threshold`. Name presets in the config's `presets`, or on the
378
495
  command line with `--preset`, repeated or separated by commas, as in
379
496
  `--preset effect,alchemy`, in which case the config file is optional. Presets
380
- named in both places apply together. There are two:
497
+ named in both places apply together. Reports name a preset's rule with the
498
+ preset first, as in `alchemy/secrets/…`. There are two:
381
499
 
382
500
  - `effect`: Markdown rules in [`presets/effect/`](./presets/effect/), lifted
383
501
  from the [effect-solutions](https://github.com/kitlangton/effect-solutions)
@@ -395,6 +513,32 @@ of its own: `--preset effect/basics` applies only the rules under
395
513
  A topic's rules keep the ids they have in the whole preset, so a topic and its
396
514
  preset share cached judgments, and naming both applies each rule once.
397
515
 
516
+ A config's `overrides` changes a rule by the id a report names it with, a
517
+ preset's with the preset first, without copying its text into `rules`: `off`
518
+ drops it, `warning` or `error` sets its level, and `{ level, threshold }` sets
519
+ either. An id that no preset or project rule has refuses the run, so a typo
520
+ does not go unnoticed; a rule of a preset the run does not name is accepted.
521
+
522
+ ```ts
523
+ export default defineConfig({
524
+ presets: ["effect", "alchemy"],
525
+ overrides: {
526
+ "alchemy/providers/idempotent-delete": { level: "warning", threshold: 0.9 },
527
+ "effect/basics/instrument-with-pipe": "off",
528
+ },
529
+ });
530
+ ```
531
+
532
+ `effect`'s four rules about tests, `testing/test-clock-for-time`,
533
+ `services/fresh-layer-per-test`, `services/test-layers-are-in-memory`, and
534
+ `config/tests-provide-values-directly`, say `tests: only`: they judge tests and
535
+ nothing else, and every other preset rule skips tests.
536
+
537
+ `alchemy`'s `providers/idempotent-delete` reports warnings. Whether a delete
538
+ fails on a resource that is already gone is a fact about the API, which the
539
+ file does not show, so the rule also flags deletes of APIs that succeed
540
+ anyway; see [the study](eval/studies/idempotent-delete.md).
541
+
398
542
  A preset rule says "must" only where its source makes a requirement, and
399
543
  "should" where the source gives advice. In `effect`, three rules are
400
544
  guidelines: network calls carry a timeout and a retry schedule, unless their
@@ -443,12 +587,13 @@ whose types have no requirements to find. The preset's
443
587
  Highest first: `--threshold` on the command line, the config file, presets in
444
588
  order (a later preset wins), then the defaults `jev-latest` and `0.8`. A rule
445
589
  in `rules` replaces a preset rule with the same id. A rule's own `threshold`
446
- beats all of the above for that rule.
590
+ beats all of the above for that rule, and an entry in `overrides` beats the
591
+ rule's own `threshold` and `level`.
447
592
 
448
593
  ## How a file is judged
449
594
 
450
- 1. One Jev request per file. The state is the file's numbered lines and
451
- nothing else. Each rule is one `noul` (yes/no probability) question that
595
+ 1. One Jev request per file. The state is the file's numbered lines, without
596
+ their comments (see [Comments](#comments)), and nothing else. Each rule is one `noul` (yes/no probability) question that
452
597
  carries the rule's description and its code under its words, `must` and
453
598
  `never` (or a guideline's `should` and `should_not`): whether the file
454
599
  diverges from the pattern `must` shows, with `never` as an example of
@@ -481,8 +626,9 @@ gives everyone and CI the same findings without paying for them again, and a
481
626
  pull request changes the judgments of only the files it changes.
482
627
 
483
628
  `files/` holds Jev's answers by the content they are about: each cache file is
484
- named for the hash of a source file's content, so a moved or copied file keeps
485
- its judgments, and identical files share them. Each answer sits under a
629
+ named for the hash of a source file's content as Jev reads it, without its
630
+ comments, so a moved or copied file keeps its judgments, identical files share
631
+ them, and a change to comments alone re-judges nothing. Each answer sits under a
486
632
  fingerprint of the model and the question asked for the rule, which carries
487
633
  the rule's text. A changed file re-judges every rule for that file. An edited
488
634
  rule re-judges only that rule, and an adhere that asks its questions
@@ -498,9 +644,12 @@ every file, adhere prunes the cache: it deletes answers about content no file
498
644
  has, and folds what is left into one file per content. Answers to rules the
499
645
  run left out stay, so a run with other presets or rules, or with one topic,
500
646
  loses nothing another run still asks; answers to a rule's old texts stay too,
501
- until the content they are about is gone. A run narrowed by `--filter` does not
502
- prune. To keep the
503
- cache out of diffs, mark it as generated in `.gitattributes`:
647
+ until the content they are about is gone. Answers about a file the run read
648
+ but judged against no rule, such as a test when no rule judges tests, stay as
649
+ well, while a file the config excludes is not read, so answers about it go. A
650
+ run narrowed by `--filter` does not prune.
651
+
652
+ To keep the cache out of diffs, mark it as generated in `.gitattributes`:
504
653
 
505
654
  ```text
506
655
  .adhere/cache/** linguist-generated -diff
@@ -520,12 +669,55 @@ every file is cached needs no key and no network.
520
669
 
521
670
  ## Agent skill
522
671
 
523
- [`skills/adhere/SKILL.md`](./skills/adhere/SKILL.md) teaches an agent to
524
- gather a repo's conventions into rule files, configure adhere, and calibrate
525
- thresholds. Install it with the [skills](https://github.com/vercel-labs/skills)
526
- CLI: `skills add darkmatter/adhere`. The binary carries the same file:
527
- `adhere skill` prints it, so `adhere skill > .agents/skills/adhere/SKILL.md`
528
- works without a checkout.
672
+ Two skills teach an agent to work with adhere:
673
+
674
+ - [`skills/adhere/SKILL.md`](./skills/adhere/SKILL.md) gathers a repo's
675
+ conventions into rule files, configures adhere, and calibrates thresholds.
676
+ - [`skills/adhere-fix/SKILL.md`](./skills/adhere-fix/SKILL.md) verifies and
677
+ fixes the findings `lint` reports: it checks each against its rule and the
678
+ code, fixes the real ones, reports the false ones with the evidence, and says
679
+ when a rule is wrong more often than right.
680
+
681
+ Install them with the [skills](https://github.com/vercel-labs/skills) CLI:
682
+ `skills add darkmatter/adhere`. The binary carries both: `adhere skill` prints
683
+ the first and `adhere skill fix` the second, so
684
+ `adhere skill fix > .agents/skills/adhere-fix/SKILL.md` works without a
685
+ checkout.
686
+
687
+ ### In CI
688
+
689
+ Pipe the fix skill into an agent that runs without asking. The skill tells it
690
+ that invoking the skill permits every `adhere lint` run and its cost, and
691
+ `adhere skill fix` prints the skill of the adhere that runs, so its flags
692
+ match:
693
+
694
+ ```sh
695
+ adhere skill fix | codex exec --yolo
696
+ adhere skill fix | claude -p --dangerously-skip-permissions
697
+ ```
698
+
699
+ Both turn off the agent's own permission checks, which suits a runner thrown
700
+ away after the job. To keep Codex in its sandbox, let the sandbox reach
701
+ `api.typesafe.ai`:
702
+
703
+ ```sh
704
+ adhere skill fix | codex exec --sandbox workspace-write \
705
+ -c sandbox_workspace_write.network_access=true -c approval_policy=never
706
+ ```
707
+
708
+ - Set `TYPESAFE_API_KEY` from a secret.
709
+ - The agent adds no `adhere-ignore` comments or `@adhere` notes unless
710
+ `ADHERE_ALLOW_SUPPRESSIONS=1` is set in its environment, as in
711
+ `adhere skill fix | ADHERE_ALLOW_SUPPRESSIONS=1 codex exec --yolo`. It then
712
+ suppresses a finding it shows is false, or that three fixes did not clear,
713
+ with the evidence in the comment. Without it, the agent leaves those
714
+ findings, and its report ends by naming them and the variable.
715
+ - End the job with `adhere lint --yes`. The agent exits 0 whatever it left,
716
+ so this step passes or fails the job; it judges only the files the agent
717
+ changed, and reads the rest from the cache.
718
+ - Do not run it on pull requests from forks. The agent reads their code with
719
+ your secrets in reach and the network open, so code written to steer it can
720
+ send them out.
529
721
 
530
722
  ## Development
531
723
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Lints a repository against the rules in .adhere/, judged by Jev.",
6
6
  "license": "MIT",
@@ -68,10 +68,10 @@
68
68
  }
69
69
  },
70
70
  "optionalDependencies": {
71
- "@drkmttr/adhere-darwin-arm64": "0.9.4",
72
- "@drkmttr/adhere-darwin-x64": "0.9.4",
73
- "@drkmttr/adhere-linux-arm64": "0.9.4",
74
- "@drkmttr/adhere-linux-x64": "0.9.4",
75
- "@drkmttr/adhere-win32-x64": "0.9.4"
71
+ "@drkmttr/adhere-darwin-arm64": "0.10.0",
72
+ "@drkmttr/adhere-darwin-x64": "0.10.0",
73
+ "@drkmttr/adhere-linux-arm64": "0.10.0",
74
+ "@drkmttr/adhere-linux-x64": "0.10.0",
75
+ "@drkmttr/adhere-win32-x64": "0.10.0"
76
76
  }
77
77
  }
@@ -2,13 +2,17 @@
2
2
  description: Errors that crossed RPC or a workflow replay must be matched by their tag and data fields, never with instanceof, prototype methods, or object identity, which do not survive serialization, and RPC arguments must be serializable values, never functions or class instances.
3
3
  ---
4
4
 
5
- ```ts must
5
+ ## Must
6
+
7
+ ```ts
6
8
  const value = yield* stub.get(key).pipe(
7
9
  Effect.catchTag("KeyMissing", (error) => Effect.succeed(`missing: ${error.key}`)),
8
10
  );
9
11
  ```
10
12
 
11
- ```ts never
13
+ ## Never
14
+
15
+ ```ts
12
16
  const value = yield* stub.get(key).pipe(
13
17
  Effect.catchAll((error) => (error instanceof KeyMissing ? Effect.succeed("missing") : Effect.fail(error))),
14
18
  );
@@ -2,14 +2,18 @@
2
2
  description: Input from outside the stack, such as browsers, partners, or public URLs, should be validated with a schema through Effect RPC or HttpApi, while calls between the stack's own runtimes should use schemaless RPC through bindings, and unvalidated payloads should not cross a trust boundary.
3
3
  ---
4
4
 
5
- ```ts should
5
+ ## Should
6
+
7
+ ```ts
6
8
  const Tasks = HttpApiGroup.make("tasks").add(
7
9
  HttpApiEndpoint.post("create", "/tasks", { payload: Schema.Struct({ title: Schema.String }) }),
8
10
  );
9
11
  const greeter = yield* Cloudflare.Workers.bindWorker(Greeter); // internal call
10
12
  ```
11
13
 
12
- ```ts should not
14
+ ## Should not
15
+
16
+ ```ts
13
17
  fetch: Effect.gen(function* () {
14
18
  const request = yield* HttpServerRequest.HttpServerRequest;
15
19
  const body = (yield* request.json) as { title: string; ownerId: string }; // public URL, no validation
@@ -2,12 +2,16 @@
2
2
  description: Migrations must be generated and reviewed ahead of time, committed with the schema, and applied from the committed files; a deploy, deploy hook, or Action must never generate migration SQL, and schema changes must never run from an HTTP route.
3
3
  ---
4
4
 
5
- ```ts must
5
+ ## Must
6
+
7
+ ```ts
6
8
  // package.json: "db:generate": "drizzle-kit generate" (run locally, then commit ./drizzle)
7
9
  const db = yield* Cloudflare.D1.Database("AppDb", { migrations: "./drizzle" });
8
10
  ```
9
11
 
10
- ```ts never
12
+ ## Never
13
+
14
+ ```ts
11
15
  // package.json: "deploy": "drizzle-kit generate && alchemy deploy"
12
16
  fetch: Effect.gen(function* () {
13
17
  if (url.pathname === "/setup") yield* sql`CREATE TABLE users (id text primary key)`;
@@ -2,13 +2,17 @@
2
2
  description: A Hyperdrive connection's origin should be the database's direct endpoint, and clients that connect without Hyperdrive should use the provider's pooled endpoint; Hyperdrive should not point at a provider pooler, which stacks two poolers.
3
3
  ---
4
4
 
5
- ```ts should
5
+ ## Should
6
+
7
+ ```ts
6
8
  const hyperdrive = yield* Cloudflare.Hyperdrive.Connection("AppHyperdrive", {
7
9
  origin: branch.origin,
8
10
  dev: branch.pooledOrigin,
9
11
  });
10
12
  ```
11
13
 
12
- ```ts should not
14
+ ## Should not
15
+
16
+ ```ts
13
17
  const hyperdrive = yield* Cloudflare.Hyperdrive.Connection("AppHyperdrive", { origin: role.pooledOrigin });
14
18
  ```
@@ -2,10 +2,14 @@
2
2
  description: A migration runner must connect with the database's direct URL, never Hyperdrive's runtime connection string or an Accelerate or pooled URL.
3
3
  ---
4
4
 
5
- ```ts must
5
+ ## Must
6
+
7
+ ```ts
6
8
  export default defineConfig({ dialect: "postgresql", dbCredentials: { url: process.env.DIRECT_DATABASE_URL! } });
7
9
  ```
8
10
 
9
- ```ts never
11
+ ## Never
12
+
13
+ ```ts
10
14
  export default defineConfig({ dialect: "postgresql", dbCredentials: { url: process.env.ACCELERATE_URL! } });
11
15
  ```