@drkmttr/adhere 0.9.5 → 0.11.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 (113) hide show
  1. package/README.md +406 -90
  2. package/package.json +7 -7
  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/private-endpoint-at-runtime.md +6 -2
  10. package/presets/alchemy/data/single-migration-owner.md +6 -2
  11. package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
  12. package/presets/alchemy/durable/io-inside-steps.md +6 -2
  13. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
  14. package/presets/alchemy/durable/stable-step-names.md +6 -2
  15. package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
  16. package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
  17. package/presets/alchemy/providers/idempotent-delete.md +8 -3
  18. package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
  19. package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
  20. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
  21. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
  22. package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
  23. package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
  24. package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
  25. package/presets/alchemy/resources/stable-logical-ids.md +6 -2
  26. package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
  27. package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
  28. package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
  29. package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
  30. package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
  31. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
  32. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
  33. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
  34. package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
  35. package/presets/alchemy/secrets/redacted-until-use.md +6 -2
  36. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
  37. package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
  38. package/presets/alchemy/security/github-secret-not-variable.md +6 -2
  39. package/presets/alchemy/security/identity-from-verified-session.md +6 -2
  40. package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
  41. package/presets/alchemy/security/least-privilege-bindings.md +6 -2
  42. package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
  43. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
  44. package/presets/effect/basics/gen-for-sequencing.md +7 -3
  45. package/presets/effect/basics/instrument-with-pipe.md +6 -2
  46. package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
  47. package/presets/effect/config/secrets-are-redacted.md +6 -2
  48. package/presets/effect/config/tests-provide-values-directly.md +7 -2
  49. package/presets/effect/config/validate-with-schema.md +6 -2
  50. package/presets/effect/data/brand-meaningful-primitives.md +7 -3
  51. package/presets/effect/data/variants-are-tagged-unions.md +7 -3
  52. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  53. package/presets/effect/errors/defects-for-bugs.md +6 -2
  54. package/presets/effect/errors/wrap-external-errors.md +6 -2
  55. package/presets/effect/services/dependencies-through-layers.md +7 -3
  56. package/presets/effect/services/fresh-layer-per-test.md +7 -2
  57. package/presets/effect/services/memoize-parameterized-layers.md +6 -2
  58. package/presets/effect/services/no-mutable-state.md +6 -2
  59. package/presets/effect/services/operations-have-no-requirements.md +6 -2
  60. package/presets/effect/services/test-layers-are-in-memory.md +7 -2
  61. package/presets/effect/testing/test-clock-for-time.md +7 -2
  62. package/presets/react/effects/clean-up-what-they-start.md +23 -0
  63. package/presets/react/effects/event-logic-in-handlers.md +29 -0
  64. package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
  65. package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
  66. package/presets/react/effects/notify-parents-in-handlers.md +29 -0
  67. package/presets/react/server/no-private-data-to-client.md +23 -0
  68. package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
  69. package/presets/react/state/no-contradictory-state.md +17 -0
  70. package/presets/react/state/no-props-copied-into-state.md +23 -0
  71. package/presets/react/state/store-ids-not-copies.md +20 -0
  72. package/presets/security/constant-time-secret-compare.md +22 -0
  73. package/presets/security/no-secrets-in-output.md +19 -0
  74. package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
  75. package/presets/security/parameterized-queries.md +16 -0
  76. package/presets/typescript/async/concurrent-independent-work.md +23 -0
  77. package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
  78. package/presets/typescript/design/no-argument-mutation.md +20 -0
  79. package/presets/typescript/errors/no-swallowed-errors.md +29 -0
  80. package/presets/typescript/errors/release-on-every-path.md +29 -0
  81. package/presets/typescript/testing/assert-outcomes.md +22 -0
  82. package/presets/typescript/testing/independent-tests.md +41 -0
  83. package/presets/typescript/types/assertions-only-when-proven.md +20 -0
  84. package/presets/typescript/types/derive-dont-restate.md +24 -0
  85. package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
  86. package/presets/typescript/types/validate-external-data.md +21 -0
  87. package/skills/adhere/SKILL.md +41 -12
  88. package/skills/adhere-fix/SKILL.md +180 -0
  89. package/src/cli.ts +59 -14
  90. package/src/comments.ts +36 -0
  91. package/src/config.ts +126 -14
  92. package/src/contradictions.ts +10 -2
  93. package/src/excerpt.ts +78 -58
  94. package/src/highlight.ts +2 -0
  95. package/src/index.ts +1 -1
  96. package/src/init.ts +119 -12
  97. package/src/main.ts +0 -1
  98. package/src/markdown.ts +83 -14
  99. package/src/models/Audit.ts +2 -0
  100. package/src/presets.ts +12 -3
  101. package/src/rules.ts +36 -3
  102. package/src/services/AdhereConfig.ts +36 -13
  103. package/src/services/AuditCache.ts +31 -5
  104. package/src/services/Jev.http.ts +76 -11
  105. package/src/services/Jev.ts +191 -73
  106. package/src/services/SourceWalker.ts +51 -13
  107. package/src/suppress.ts +119 -0
  108. package/src/version.ts +25 -1
  109. package/src/workflows/audit.ts +192 -40
  110. package/src/workflows/format.ts +71 -24
  111. package/presets/alchemy/data/parameterized-sql.md +0 -13
  112. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -15
  113. package/presets/effect/basics/external-calls-are-resilient.md +0 -17
package/README.md CHANGED
@@ -1,20 +1,33 @@
1
- # adhere
1
+ <div align="center">
2
+ <h1>adhere</h1>
3
+ <sup>A linter for non-deterministic rules. Powered by Typesafe</sup>
4
+ </div>
2
5
 
3
- A linter for rules a normal linter cannot check. A rule is a one-sentence
4
- description in RFC 2119's words, like "business logic must live in services",
5
- with code that must be written that way, code that must never be, or both. For
6
- each source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
6
+ A linter for rules a normal linter cannot check. Describe rules using RFC2119
7
+ language, like "business logic MUST live in services", or "A 3rd-party API going down
8
+ SHOULD NOT also take us down". A rule also includes a good example, a bad example, or
9
+ both (which is highly recommended according to [our studies](eval/studies/example-count.md).)
10
+
11
+ For each source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
7
12
  model) whether the file breaks each rule, gets a calibrated probability per
8
- rule, and reports the ones above a threshold with the line Jev points at. The
9
- report uses the same frame as `vp lint`.
13
+ rule, and reports the ones above a threshold with the section of the file Jev
14
+ points at. The report uses the same frame as `vp lint`.
15
+
16
+ Here's a demo of what the output looks like:
17
+
18
+ <a href="https://asciinema.org/a/1266511" target="_blank"><img width="400" src="https://asciinema.org/a/1266511.svg" /></a>
19
+
10
20
 
11
- Deterministic rules (substring matches, type checks) belong in a normal linter;
12
- `adhere validate` points out a rule that looks like one.
21
+ Rules a normal linter can check exactly, such as a banned import or a type
22
+ error, belong in that linter. While it lints, adhere asks Jev whether a normal
23
+ linter could check each of your rules, and [`adhere validate`](#validate) lists
24
+ rules that it thinks belong in a regular linter, and also detects any
25
+ contradictions in your rules.
13
26
 
14
- A preset for effect is included which can be run without setup:
27
+ Presets for TypeScript, React, security, Effect, and alchemy are included, and run without setup:
15
28
 
16
29
  ```sh
17
- TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset effect
30
+ TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset typescript
18
31
  ```
19
32
 
20
33
  ## Install
@@ -27,7 +40,7 @@ To pin the version in a repo, for CI or scripts, add it as a dev dependency
27
40
  and run `npx adhere`.
28
41
 
29
42
  `adhere` is a prebuilt executable for macOS and Linux on arm64 and x64 and for
30
- Windows on x64. .
43
+ Windows on x64.
31
44
 
32
45
  adhere sends each file it judges to Jev at `api.typesafe.ai`, authenticated
33
46
  with a TypeSafe AI API key: the one `adhere login` saved, or
@@ -36,7 +49,7 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
36
49
  ## Quick start
37
50
 
38
51
  ```sh
39
- adhere init # .adhere/config.ts and two example rules
52
+ adhere init # .adhere/config.ts, two example rules, and the dependency
40
53
  adhere login # save your TypeSafe AI API key, once
41
54
  adhere validate # Checks your rules' wording, and contradictions using Jev
42
55
  adhere lint # audit the working directory
@@ -49,6 +62,9 @@ built-in Effect rules. A finding looks like this:
49
62
  ```text
50
63
  × data/brand-ports (0.93): A port must be a branded, range-checked integer, never a bare number.
51
64
  ╭─[src/server.ts:6:3]
65
+ 1 │ import { Effect } from "effect";
66
+ 2 │ import { listen } from "./listen.ts";
67
+ 3 │
52
68
  4 │ export const serve = Effect.gen(function* () {
53
69
  5 │ const host = process.env.HOST ?? "localhost";
54
70
  6 │ const port: number = Number(process.env.PORT ?? 3000);
@@ -68,17 +84,45 @@ Found 1 error.
68
84
 
69
85
  The header is the rule id, Jev's probability, and the rule's description. A
70
86
  preset's rule has the preset's name first, as in
71
- `effect/basics/external-calls-are-resilient`, so a report that mixes presets
87
+ `effect/basics/gen-for-sequencing`, so a report that mixes presets
72
88
  with a repo's own rules says where each came from.
73
- Under it is the line Jev points at, underlined, with the code around it: the
74
- largest statement around the line that is 30 lines or fewer, usually the whole
75
- function, and up to 3 lines of whole statements on either side. The
76
- hint is the rule's code that must be written; a rule with only code that must
77
- never be written shows that code, labeled `never:`, instead. On a terminal, the report is in color and the code
78
- in it is highlighted. The exit code is 0 when nothing is reported and 1 when
79
- something is. It is also 1 when the run refuses, for example on an invalid
80
- config or rule file, a missing API key, or an unknown command or flag; a
81
- refusal prints its reason.
89
+ Under it is the section of the file Jev points at, with the line it names
90
+ underlined, or the lines, when it names several, marked with a bar beside the
91
+ code. adhere splits each file into sections of whole statements, such as
92
+ its imports, constants, and functions, packed in order into runs of at most 30
93
+ lines; a class or function longer than that splits into its methods or the
94
+ statements of its body. Jev reads the file as these sections and chooses one,
95
+ and in the same request the line in it where the violation starts and the one
96
+ where it ends: on the eval it judged as well that way, and the lines it named
97
+ held a violation for 94% of real findings, most often as one line. When it
98
+ names no lines, the section is shown alone, at its first line.
99
+
100
+ Jev is also asked whether the file shows enough to decide the rule at all:
101
+ whether it turns on something the file does not show, such as what another
102
+ file, a library, or a service does. When Jev's probability that it does is
103
+ below 0.6, the finding gets a warning under its code, with the probability
104
+ that it does not, and help on adding what is missing:
105
+
106
+ ```text
107
+ warning: this file may not show enough to check this rule (Jev: 0.55 that it does not)
108
+ help: add what the code relies on outside this file as a note Jev reads:
109
+ // @adhere <the fact>, and how you know it
110
+ ```
111
+
112
+ On the eval, most findings with this warning were false, where about one in
113
+ five of all findings was: check what the code relies on outside the file
114
+ before acting on it. What you find can go in an `@adhere` note, which Jev
115
+ reads (see [Comments](#comments)). `sufficiencyThreshold` in the config, or `--sufficiency-threshold`, sets
116
+ the cutoff. The warning changes neither the finding's level nor the exit code.
117
+
118
+ The hint is the rule's code that must be written; a rule with only code that must
119
+ never be written shows that code, labeled `never:`, instead. A rule whose
120
+ level is `warning` reports its findings with `⚠` in place of `×`, in amber. On
121
+ a terminal, the report is in color and the code in it is highlighted. The exit
122
+ code is 1 when an error is reported, and 0 when nothing is, or only warnings;
123
+ `--deny-warnings` fails the run on warnings too. It is also 1 when the run
124
+ refuses, for example on an invalid config or rule file, a missing API key, or
125
+ an unknown command or flag; a refusal prints its reason.
82
126
 
83
127
  ## Usage
84
128
 
@@ -86,10 +130,12 @@ refusal prints its reason.
86
130
  adhere lint # audit the working directory
87
131
  adhere lint --preset effect # add a built-in rule set; the config becomes optional
88
132
  adhere lint --threshold 0.9 # replace the config's threshold; per-rule thresholds still apply
133
+ adhere lint --sufficiency-threshold 0.5 # warn on fewer findings that may turn on other files
89
134
  adhere lint --yes # send the requests without asking first
90
135
  adhere lint --limit 500 # judge at most 500 checks; the rest wait for the next run
91
136
  adhere lint --rpm 30 # send at most 30 requests a minute
92
137
  adhere lint --filter 'src/**' # read only the files a glob matches
138
+ adhere lint --deny-warnings # fail on warnings as well as errors
93
139
  adhere lint --log-level debug # log each request to Jev on stderr
94
140
  adhere validate # check the rules' wording; ask Jev whether any contradict
95
141
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
@@ -121,15 +167,10 @@ Those carry about 1.9 million input tokens: about $0.08 at $0.042 per million, a
121
167
  The cost is adhere's estimate of the input tokens times the model's price.
122
168
  Jev charges only for input tokens: $0.042 a million for `jev-latest`, per
123
169
  [TypeSafe AI's models page](https://docs.typesafe.ai/models) in September 2026.
124
- The estimate runs about a tenth high, and locating findings, which depends on
125
- what judging finds, comes on top. For a model adhere has no price for, the plan
126
- gives the tokens alone.
170
+ For a model adhere has no price for, the plan gives the tokens alone.
127
171
 
128
172
  It asks only with a terminal on stdin and stdout, and never when the cache
129
- answers every check; `--yes` sends without asking. While it runs, a counter on
130
- stderr shows the files done, the requests sent, and the findings so far, and
131
- the report goes to stdout. A request Jev refuses stops the run with the file,
132
- the HTTP status, and Jev's reason. Files finished before it are cached, so
173
+ answers every check; `--yes` sends without asking. Files finished before it are cached, so
133
174
  running again continues from there.
134
175
 
135
176
  The firewall in front of Jev's API can refuse a request whose code reads to
@@ -147,13 +188,61 @@ next run with the same limit picks up where this one stopped. `--limit 0` shows
147
188
  the plan and judges nothing. `--rpm <requests>` sends at most that many requests
148
189
  to Jev a minute, evenly spaced, retries included, and the plan says about how
149
190
  long they take. `--filter <glob>` reads only the files whose path from the
150
- working directory matches, such as `src/**` or `**/*.service.ts`; repeat it for
151
- more, and start a pattern with `!` to leave out what it matches. Together:
191
+ working directory matches, such as `src/**` or `**/*.service.ts` (if using globs,
192
+ wrap with single quotes to avoid expansion); repeat it for more, and start a
193
+ pattern with `!` to leave out what it matches. Together:
152
194
 
153
195
  ```sh
154
196
  adhere lint --filter 'packages/api/**' --filter '!**/generated/**' --limit 200 --rpm 30
155
197
  ```
156
198
 
199
+ ### Comments
200
+
201
+ Jev reads a file's code, not its comments. A comment changes nothing the code
202
+ does, so it should not change the report, yet Jev takes what comments claim at
203
+ their word: in a study of one alchemy rule, comments saying a delete succeeds
204
+ on a missing resource hid 8 of its 9 real violations, and taking comments out
205
+ found all nine. adhere takes every comment out before it hashes or sends a
206
+ file, blanking it so every line keeps its number; the report's excerpts still
207
+ show them.
208
+
209
+ A comment that says `@adhere` anywhere in it is a note for Jev, and stays.
210
+ Write one for a fact the code relies on that the file cannot show, once you
211
+ have checked it, with how you know:
212
+
213
+ ```ts
214
+ /**
215
+ * Deletes the activity. @adhere DeleteActivity succeeds on a missing
216
+ * activity, so no not-found error needs catching; probed 2026-09-25.
217
+ */
218
+ ```
219
+
220
+ A note informs Jev; it suppresses nothing, and Jev still judges the code
221
+ around it. `includeComments: true` in the config sends every comment, for a
222
+ project whose rules are about comments, such as doc comments on exports.
223
+
224
+ ### Suppressing a finding
225
+
226
+ A finding Jev got wrong is suppressed in the code, with a comment saying why:
227
+
228
+ ```ts
229
+ // adhere-ignore alchemy/providers/idempotent-delete -- DeleteActivity succeeds on a missing activity; probed 2026-09-25
230
+ delete: Effect.fn(function* ({ output }) {
231
+ yield* sfn.deleteActivity({ activityArn: output.activityArn });
232
+ }),
233
+ ```
234
+
235
+ On a line of its own, `adhere-ignore` covers the statement that starts on the
236
+ next line of code: here the whole handler, wherever in it Jev points, since the
237
+ lines it points at can move between runs. A finding without lines is covered
238
+ when its section overlaps the statement. At the end of a line of code, it
239
+ covers the statement that starts on that line. `adhere-ignore-file`, anywhere
240
+ in a file, covers the whole file, and the rules it names are not judged there
241
+ at all. A comment names rules as a report does, a preset's with its preset
242
+ first, separated by commas; what follows `--` is the reason, for whoever
243
+ reads the code next. Jev never reads these comments, even with
244
+ `includeComments: true`, and the summary counts the findings they suppress.
245
+
157
246
  ### Logging a run
158
247
 
159
248
  `--log-level debug` logs a run's work on stderr: the config it loaded, how many
@@ -172,21 +261,52 @@ adhere lint --log-level debug 2> adhere.log
172
261
 
173
262
  ### What gets read
174
263
 
175
- `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
176
- `.test.ts`, and config files. When the working directory contains `agents/`,
177
- `apps/`, or `packages/`, only those trees are read. Below the working
178
- directory, anything under `node_modules/`, `dist/`, `coverage/`, `vendor/`,
179
- `e2e/`, `references/`, `.adhere/`, `.agents/`, `.claude/`, `.direnv/`,
180
- `.alchemy/`, or `.vite/` is skipped; the directories above it do not count.
181
- `.tsx` files are not read, and `.gitignore` is not consulted.
264
+ `adhere lint` reads the TypeScript files under the working directory, `.ts`,
265
+ `.tsx`, `.mts`, and `.cts`, except declaration files such as `.d.ts` and
266
+ config files. When the working directory contains `agents/`, `apps/`, or
267
+ `packages/`, only those trees are read. Below the working directory, anything
268
+ under `node_modules/`, `dist/`, `coverage/`, `vendor/`, `e2e/`, `references/`,
269
+ `.adhere/`, `.agents/`, `.claude/`, `.direnv/`, `.alchemy/`, or `.vite/` is
270
+ skipped; the directories above it do not count. JavaScript files are not read,
271
+ and `.gitignore` is not consulted.
272
+
273
+ Tests are judged only by rules about tests. A test is a `.test` or `.spec`
274
+ file, such as `a.test.ts` or `Button.spec.tsx`; a fixture or test helper named
275
+ as one, such as `fixtures.ts`, `GitHubHttpFixtures.ts`,
276
+ `ledger-copy.fixture.ts`, `linear-test-helpers.ts`, or `test-utils.ts`; or any
277
+ file under a `test/`, `tests/`, `__tests__/`,
278
+ `testing/`, `fixtures/`, `fixture/`, `__fixtures__/`, or `__mocks__/`
279
+ directory, and only a rule whose front matter says `tests` judges
280
+ one (see [Rules as Markdown files](#rules-as-markdown-files)). On alchemy, 38%
281
+ of the findings from rules not about tests were in test helpers and fixtures.
282
+
283
+ A config's `exclude` lists globs, relative to the working directory, of files
284
+ no rule judges, such as generated code. It leaves them out of every run, as
285
+ `--filter '!<glob>'` leaves them out of one:
286
+
287
+ ```ts
288
+ export default defineConfig({ exclude: ["**/generated/**"] });
289
+ ```
182
290
 
183
291
  ### Init
184
292
 
185
- `adhere init` writes `.adhere/config.ts` and two example rules. It is safe to
186
- rerun: by default it reports existing files as skipped and does not clobber
293
+ `adhere init` writes `.adhere/config.ts`, the `.adhere/tsconfig.json` that gives
294
+ the editor its types (see [Config](#config)), and two example rules. It is safe
295
+ to rerun: by default it reports existing files as skipped and does not clobber
187
296
  them. `--force` overwrites them. A config already at another accepted path is
188
297
  kept, and no second one is added, even with `--force`.
189
298
 
299
+ When there is a `package.json`, init also adds `@drkmttr/adhere` to its
300
+ `devDependencies`, so the config's `defineConfig` has its types in the editor. It runs
301
+ `<manager> add -D @drkmttr/adhere` with the package manager whose lockfile
302
+ (`bun.lock`, `bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`)
303
+ sits beside the `package.json`, or npm when there is none. With pnpm it adds
304
+ `--ignore-workspace-root-check`, so the install also works at a workspace root,
305
+ and `--config.strict-dep-builds=false`, so pnpm 11 skips the build script of
306
+ msgpackr-extract, an optional native add-on that comes with `effect` and that
307
+ adhere never uses, with a warning instead of failing.
308
+ It skips the install when `package.json` already lists adhere.
309
+
190
310
  ### Validate
191
311
 
192
312
  `adhere validate` loads the config, rule files, and presets the way `lint`
@@ -252,12 +372,15 @@ repo being audited, at one of these paths (keep one):
252
372
  - `.adhere.config.ts`
253
373
 
254
374
  ```ts
255
- import type { Config } from "@drkmttr/adhere";
375
+ import { defineConfig } from "@drkmttr/adhere";
256
376
 
257
- export default {
377
+ export default defineConfig({
258
378
  model: "jev-latest", // optional, default "jev-latest"
259
379
  threshold: 0.8, // optional, default 0.8
380
+ sufficiencyThreshold: 0.6, // optional, default 0.6: below it, a finding warns to check outside the file
260
381
  presets: ["effect"], // optional, built-in rule sets
382
+ exclude: ["**/generated/**"], // optional, files no rule judges
383
+ overrides: { "effect/basics/instrument-with-pipe": "off" }, // optional, see Presets
261
384
  rules: {
262
385
  "data/brand-meaningful-primitives": {
263
386
  description:
@@ -270,16 +393,24 @@ type UserId = typeof UserId.Type
270
393
  threshold: 0.8, // optional per-rule override
271
394
  },
272
395
  },
273
- } satisfies Config;
396
+ });
274
397
  ```
275
398
 
276
399
  The default export is decoded with Effect `Schema`. An invalid shape refuses
277
- the run. The `import type` is erased at runtime. `defineConfig({...})` from
278
- `@drkmttr/adhere` does the same thing as `satisfies Config`, and the executable
279
- supplies it whether or not the repo has the package installed; the editor's
280
- types come from the package as a dev dependency. A config can import other
281
- files by relative path, but no packages besides `@drkmttr/adhere`: the
282
- executable does not resolve `node_modules`.
400
+ the run. `defineConfig` types the config for the editor and returns it as it
401
+ is; `satisfies Config`, with `import type { Config }`, does the same. The
402
+ executable supplies `@drkmttr/adhere` to the config whether or not the repo has
403
+ the package installed, and the editor's types come from the package, which
404
+ `adhere init` adds as a dev dependency. A config can import other files by
405
+ relative path, but no packages besides `@drkmttr/adhere`: the executable does
406
+ not resolve `node_modules`.
407
+
408
+ A tsconfig's globs skip dot directories, so the editor opens
409
+ `.adhere/config.ts` outside any project, where it cannot resolve the package's
410
+ types: they are reached only by `bundler`, `node16`, or `nodenext` resolution.
411
+ `adhere init` writes `.adhere/tsconfig.json`, a project for the config alone
412
+ with `bundler` resolution. For a config written by hand, add that file, or
413
+ include `.adhere/config.ts` in a tsconfig that resolves the same way.
283
414
 
284
415
  `rules` in the config, inline as above or as a directory (below), replaces the
285
416
  `.adhere/` rule files: none of them, root or nested, is read then.
@@ -291,12 +422,19 @@ config and the cache (neither is read as a rule). The path without `.md` is the
291
422
  rule id, so `.adhere/data/brand-ports.md` is `data/brand-ports`. When that
292
423
  directory exists, it is read without any config.
293
424
 
294
- A file is front matter, then a body. The body's code goes in fences tagged,
295
- after the language, with RFC 2119's words: `must` for code that must be
296
- written, and `never` for code that must never be, which is what a violation
297
- looks like. Prose around them renders on GitHub and is ignored. An untagged
298
- fence is code that must be written, and without a fence the whole body is.
299
- `avoid`, the tag before 0.7, reads as `never`.
425
+ A file is front matter, then a body. The body's code goes in fences under
426
+ headings of RFC 2119's words: `## Must` for code that must be written, and
427
+ `## Never` for code that must never be, which is what a violation looks like.
428
+ A heading names the code in its section, which runs to the next heading at its
429
+ level or higher, so a deeper heading such as `### A bare number` stays inside
430
+ it. Only a heading that is the word alone names code, in any case and with or
431
+ without a colon: `## Never:` does, `## Never do this` does not. Prose around
432
+ the code renders on GitHub and is ignored.
433
+
434
+ A fence can instead name its code after its language, as in `ts never`, which
435
+ GitHub does not show; a fence's own word wins over its heading's. An untagged
436
+ fence outside those sections is code that must be written, and without a fence
437
+ the whole body is. `avoid`, the tag before 0.7, reads as `never`.
300
438
 
301
439
  ````md
302
440
  ---
@@ -306,22 +444,31 @@ threshold: 0.8
306
444
 
307
445
  Why: a bare `number` accepts 70000 and -1.
308
446
 
309
- ```ts must
447
+ ## Must
448
+
449
+ ```ts
310
450
  const Port = Schema.Int.pipe(
311
451
  Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
312
452
  Schema.brand("Port"),
313
453
  );
314
454
  ```
315
455
 
316
- ```ts never
456
+ ## Never
457
+
458
+ ```ts
317
459
  const port: number = Number(process.env.PORT);
318
460
  ```
319
461
  ````
320
462
 
321
463
  `description` is required, with code under `must`, `never`, or both.
322
- `threshold` is optional. A file that fails validation refuses the run with its
323
- path in the message. `loadRules(directory)` from the package root does the same
324
- load for your own tooling.
464
+ `threshold` is optional, and so is `tests`, for a rule that judges tests, which
465
+ rules otherwise skip: `tests: only` for a rule about tests, which judges
466
+ nothing else, and `tests: include` for one that holds in tests as well. So is
467
+ `level`: `level: warning` for a nit, or for a rule that tends to flag code
468
+ wrongly, reports its findings as warnings, which do not fail the run. A rule
469
+ inline in a config takes the same `tests` and `level`. A file that fails validation refuses
470
+ the run with its path in the message. `loadRules(directory)` from the package
471
+ root does the same load for your own tooling.
325
472
 
326
473
  Jev reads the code under the same words, and is asked whether the file
327
474
  diverges from the pattern `must` shows, with `never` as an example of
@@ -334,7 +481,7 @@ dropped. Neither goes in the other's place: code that must never be written,
334
481
  under `must`, reads to Jev as the pattern to follow.
335
482
 
336
483
  A rule that is a guideline rather than a requirement says "should" instead:
337
- in its description, and in fences tagged `should` and `should not` (`should`
484
+ in its description, and in `## Should` and `## Should not` headings (`should`
338
485
  and `shouldNot` inline in a config). Jev then reads the code under `should`
339
486
  and `should_not`. A rule is one or the other, so it cannot mix `must` or
340
487
  `never` with `should` or `should not`. In a config, `reference` and `avoid`,
@@ -360,18 +507,45 @@ cost is visibility: a dot directory is hidden from `ls`, and from `rg` without
360
507
  documentation can point `rules` at `docs/adhere/`:
361
508
 
362
509
  ```ts
363
- export default { presets: ["effect"], rules: "./docs/adhere" } satisfies Config;
510
+ export default defineConfig({ presets: ["effect"], rules: "./docs/adhere" });
364
511
  ```
365
512
 
366
513
  Rules read through `rules` apply project-wide.
367
514
 
515
+ ### Scoping a rule
516
+
517
+ A rule can say where it applies, apart from what it asks for, with
518
+ `appliesTo` and `excludeIf`: each a list of descriptions of code, written in
519
+ front matter as a JSON array on one line, or as an array inline in a config.
520
+
521
+ ```md
522
+ ---
523
+ description: Structured variants must be Schema.TaggedClass members of a Schema.Union, never hand-written object types joined by a tag field.
524
+ appliesTo: ["a declared union type or schema"]
525
+ excludeIf: ["a type that mirrors a third-party format whose tag key that format fixes, such as a Slack Block Kit block"]
526
+ ---
527
+ ```
528
+
529
+ Each goes to Jev as a question of its own, in the same request as the rule,
530
+ about the code that breaks the rule rather than the whole file: for
531
+ `appliesTo`, whether any of that code is as described, and for `excludeIf`,
532
+ whether all of it is. A finding stands only when Jev says yes, above 0.5, to
533
+ every `appliesTo` and to no `excludeIf`, so a file with a real violation
534
+ beside code an `excludeIf` describes keeps its finding. The scores are cached
535
+ with the rule's judgment, and editing a matcher judges the rule again, as
536
+ editing its description does. At `--log-level debug`, lint logs each finding
537
+ its matchers dropped, with their scores.
538
+
539
+ Keeping the scope out of the description keeps the description to what the
540
+ rule asks for, which the report shows, and gives each part a score of its own.
541
+
368
542
  ### Rule writing tips
369
543
 
370
544
  We've evaluated different ways of giving Jev a rule, to catch the most
371
545
  violations with the fewest false positives. In general:
372
546
 
373
547
  - Say "must" for what code must do and "never" for what it must not, in the
374
- description and as the fence tags.
548
+ description and in the headings over the code.
375
549
  - Give one example of each: one `must` block and one `never` block. Three of
376
550
  each did no better, and several of one kind alone did worse.
377
551
  - For a guideline rather than a requirement, say "should" and "should not"
@@ -389,13 +563,30 @@ A preset has the shape of a config without `presets`: `rules`, and optionally
389
563
  command line with `--preset`, repeated or separated by commas, as in
390
564
  `--preset effect,alchemy`, in which case the config file is optional. Presets
391
565
  named in both places apply together. Reports name a preset's rule with the
392
- preset first, as in `alchemy/secrets/…`. There are two:
393
-
566
+ preset first, as in `alchemy/secrets/…`. There are five:
567
+
568
+ - `typescript`: 11 rules in [`presets/typescript/`](./presets/typescript/) for
569
+ any TypeScript project, drawn from the TypeScript Handbook and the Google
570
+ TypeScript Style Guide: data from outside checked at runtime, invalid states
571
+ unrepresentable, errors never swallowed, resources released on every path,
572
+ arguments not mutated, and tests that assert outcomes and stand alone.
573
+ - `react`: 10 rules in [`presets/react/`](./presets/react/) for components
574
+ and hooks, lifted from react.dev, mostly
575
+ [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
576
+ and [Choosing the State Structure](https://react.dev/learn/choosing-the-state-structure):
577
+ logic for an event in its handler rather than an effect, effects that clean
578
+ up and ignore stale fetches, `useSyncExternalStore` for outside stores, state
579
+ that neither copies props nor contradicts itself, and Server Functions and
580
+ Server Components that guard what crosses to the client.
581
+ - `security`: 4 rules in [`presets/security/`](./presets/security/), drawn
582
+ from OWASP's cheat sheets: secrets kept out of logs, error messages, and
583
+ responses; parameterized SQL; no untrusted input in shell commands, `eval`,
584
+ or file paths; and secrets compared in constant time. It has no topics.
394
585
  - `effect`: Markdown rules in [`presets/effect/`](./presets/effect/), lifted
395
586
  from the [effect-solutions](https://github.com/kitlangton/effect-solutions)
396
587
  docs and the
397
588
  [effect/platform](https://effect.website/docs/platform/introduction/) docs.
398
- - `alchemy`: 43 rules in [`presets/alchemy/`](./presets/alchemy/) for code that
589
+ - `alchemy`: 41 rules in [`presets/alchemy/`](./presets/alchemy/) for code that
399
590
  deploys with [alchemy](https://alchemy.run), lifted from its docs and blog:
400
591
  where Config and bindings are read, which resources keep their data, how
401
592
  secrets stay out of bundles and logs, authorization on public URLs,
@@ -407,11 +598,36 @@ of its own: `--preset effect/basics` applies only the rules under
407
598
  A topic's rules keep the ids they have in the whole preset, so a topic and its
408
599
  preset share cached judgments, and naming both applies each rule once.
409
600
 
601
+ A config's `overrides` changes a rule by the id a report names it with, a
602
+ preset's with the preset first, without copying its text into `rules`: `off`
603
+ drops it, `warning` or `error` sets its level, and `{ level, threshold }` sets
604
+ either. An id that no preset or project rule has refuses the run, so a typo
605
+ does not go unnoticed; a rule of a preset the run does not name is accepted.
606
+
607
+ ```ts
608
+ export default defineConfig({
609
+ presets: ["effect", "alchemy"],
610
+ overrides: {
611
+ "alchemy/providers/idempotent-delete": { level: "warning", threshold: 0.9 },
612
+ "effect/basics/instrument-with-pipe": "off",
613
+ },
614
+ });
615
+ ```
616
+
617
+ `effect`'s four rules about tests, `testing/test-clock-for-time`,
618
+ `services/fresh-layer-per-test`, `services/test-layers-are-in-memory`, and
619
+ `config/tests-provide-values-directly`, say `tests: only`: they judge tests and
620
+ nothing else, and every other preset rule skips tests.
621
+
622
+ `alchemy`'s `providers/idempotent-delete` reports warnings. Whether a delete
623
+ fails on a resource that is already gone is a fact about the API, which the
624
+ file does not show, so the rule also flags deletes of APIs that succeed
625
+ anyway; see [the study](eval/studies/idempotent-delete.md).
626
+
410
627
  A preset rule says "must" only where its source makes a requirement, and
411
- "should" where the source gives advice. In `effect`, three rules are
412
- guidelines: network calls carry a timeout and a retry schedule, unless their
413
- client already applies both; test layers are in memory, outside integration
414
- tests; and tests provide config through a layer. Config validation accepts
628
+ "should" where the source gives advice. In `effect`, two rules are
629
+ guidelines: test layers are in memory, outside integration tests, and tests
630
+ provide config through a layer. Config validation accepts
415
631
  `Config.mapOrFail` as well as `Config.schema`, and the variants rule does not
416
632
  rule out a `switch`. The rule that a command handler only parses input is
417
633
  gone: the docs show that pattern but do not ask for it.
@@ -450,17 +666,56 @@ whose types have no requirements to find. The preset's
450
666
  `services/dependencies-through-layers` asks for that, and
451
667
  `services/operations-have-no-requirements` for the style it goes with.
452
668
 
669
+ `typescript` leaves out what typescript-eslint and ESLint check exactly:
670
+ unhandled promises (`no-floating-promises`), throwing non-errors
671
+ (`only-throw-error`), `any` (`no-explicit-any`), exhaustive switches
672
+ (`switch-exhaustiveness-check`), a lost `cause` (`preserve-caught-error`), and
673
+ empty catch blocks (`no-empty`). Four of its rules report warnings. Three are
674
+ advice that code often has reason to set aside: assertions that claim only
675
+ what the code established, types derived rather than restated, and
676
+ independent awaits run concurrently. The fourth, that HTTP requests carry a
677
+ timeout, was right on fewer than half its findings above 0.8 on the eval:
678
+ many of the rest were calls through a client with a default timeout that the
679
+ file does not show.
680
+ Two rules the eval found mostly wrong are gone: that tests use fake time
681
+ rather than real waits, and that importing a module does no work.
682
+
683
+ Where effect or alchemy had a rule that `typescript` or `security` has, that
684
+ one is kept and the other is gone: typescript's
685
+ `async/network-calls-have-timeouts` replaced effect's
686
+ `basics/external-calls-are-resilient`, and `security/parameterized-queries`
687
+ and `security/no-secrets-in-output` replaced alchemy's
688
+ `data/parameterized-sql` and `secrets/never-logged-returned-or-output`. A
689
+ project on Effect or alchemy names those presets too, for those rules.
690
+
691
+ `react` leaves out what the React Compiler's lint rules check, in
692
+ [eslint-plugin-react-hooks](https://react.dev/reference/eslint-plugin-react-hooks)'s
693
+ recommended set and in Oxlint: pure render (`purity`), props and state never
694
+ mutated (`immutability`), refs not read during render (`refs`), no `setState`
695
+ in render or synchronously in an effect (`set-state-in-render`,
696
+ `set-state-in-effect`), which covers state derived in an effect, and no
697
+ component defined inside another (`static-components`). Its rules judge `.ts`
698
+ files as well as `.tsx`, and apply only to components, hooks, effects, and
699
+ Server Functions, so other files cost their checks and find nothing.
700
+ `server/no-private-data-to-client` reports warnings: a file does not show
701
+ whether the component it passes a record to is a Client Component. So does
702
+ `state/store-ids-not-copies`, which also flags state that copies a string or
703
+ an option from a fixed list, where a copy cannot go stale.
704
+
453
705
  ### Precedence
454
706
 
455
707
  Highest first: `--threshold` on the command line, the config file, presets in
456
708
  order (a later preset wins), then the defaults `jev-latest` and `0.8`. A rule
457
709
  in `rules` replaces a preset rule with the same id. A rule's own `threshold`
458
- beats all of the above for that rule.
710
+ beats all of the above for that rule, and an entry in `overrides` beats the
711
+ rule's own `threshold` and `level`. `--sufficiency-threshold` beats the
712
+ config's `sufficiencyThreshold`, which beats the default, `0.6`.
459
713
 
460
714
  ## How a file is judged
461
715
 
462
- 1. One Jev request per file. The state is the file's numbered lines and
463
- nothing else. Each rule is one `noul` (yes/no probability) question that
716
+ 1. One Jev request per file. The state is the file's sections (see the
717
+ report, above) under their numbers from 1, as `{ "1": "…", "2": "…" }`,
718
+ without their comments (see [Comments](#comments)), and nothing else. Each rule is one `noul` (yes/no probability) question that
464
719
  carries the rule's description and its code under its words, `must` and
465
720
  `never` (or a guideline's `should` and `should_not`): whether the file
466
721
  diverges from the pattern `must` shows, with `never` as an example of
@@ -473,14 +728,22 @@ beats all of the above for that rule.
473
728
  project rule, the rule's question has a second one beside it, the linter
474
729
  check (see [Validate](#validate)), with the same fields.
475
730
  2. A second request only when at least one rule's probability is above its
476
- threshold: one `choice` question per flagged rule over the file's non-blank
477
- lines, which yields the line to report. A file with more than 255 non-blank
478
- lines is located in two steps (a block of 20 lines, then a line inside it).
731
+ threshold. Per flagged rule, when the file has more than one section, one
732
+ `choice` question among the sections' numbers, which yields the section to
733
+ report. The options carry no text of their own, since each is a key of the
734
+ state: describing each section by its first line, or by what it declares,
735
+ located no better on the eval. For each section, two `choice` questions
736
+ among its lines, each option the line's text, of the line the violation
737
+ starts on and the one it ends on; the chosen section's are underlined.
738
+ Beside them, one `noul` carrying the rule:
739
+ whether you can tell if `code` breaks it from `code` alone, without knowing
740
+ what other files, libraries, services, or configuration do. It yields the
741
+ warning (see the report, above).
479
742
  3. Jev reads at most 32k tokens of state and one question together, and 64k
480
743
  tokens in a request. adhere estimates tokens from the JSON it sends, at
481
744
  about three bytes a token. When a file's questions would not fit in one
482
745
  request, they are split across several. A file whose code and longest
483
- question would not fit together, or that has more than 5100 lines, is
746
+ question would not fit together, or that has more than 255 sections, is
484
747
  skipped and counted in the summary, and the rest of the run goes on. So is
485
748
  a file Jev itself counts as over its context, which text denser than the
486
749
  estimate, such as CJK, can cause.
@@ -493,8 +756,9 @@ gives everyone and CI the same findings without paying for them again, and a
493
756
  pull request changes the judgments of only the files it changes.
494
757
 
495
758
  `files/` holds Jev's answers by the content they are about: each cache file is
496
- named for the hash of a source file's content, so a moved or copied file keeps
497
- its judgments, and identical files share them. Each answer sits under a
759
+ named for the hash of a source file's content as Jev reads it, without its
760
+ comments, so a moved or copied file keeps its judgments, identical files share
761
+ them, and a change to comments alone re-judges nothing. Each answer sits under a
498
762
  fingerprint of the model and the question asked for the rule, which carries
499
763
  the rule's text. A changed file re-judges every rule for that file. An edited
500
764
  rule re-judges only that rule, and an adhere that asks its questions
@@ -510,9 +774,12 @@ every file, adhere prunes the cache: it deletes answers about content no file
510
774
  has, and folds what is left into one file per content. Answers to rules the
511
775
  run left out stay, so a run with other presets or rules, or with one topic,
512
776
  loses nothing another run still asks; answers to a rule's old texts stay too,
513
- until the content they are about is gone. A run narrowed by `--filter` does not
514
- prune. To keep the
515
- cache out of diffs, mark it as generated in `.gitattributes`:
777
+ until the content they are about is gone. Answers about a file the run read
778
+ but judged against no rule, such as a test when no rule judges tests, stay as
779
+ well, while a file the config excludes is not read, so answers about it go. A
780
+ run narrowed by `--filter` does not prune.
781
+
782
+ To keep the cache out of diffs, mark it as generated in `.gitattributes`:
516
783
 
517
784
  ```text
518
785
  .adhere/cache/** linguist-generated -diff
@@ -532,12 +799,55 @@ every file is cached needs no key and no network.
532
799
 
533
800
  ## Agent skill
534
801
 
535
- [`skills/adhere/SKILL.md`](./skills/adhere/SKILL.md) teaches an agent to
536
- gather a repo's conventions into rule files, configure adhere, and calibrate
537
- thresholds. Install it with the [skills](https://github.com/vercel-labs/skills)
538
- CLI: `skills add darkmatter/adhere`. The binary carries the same file:
539
- `adhere skill` prints it, so `adhere skill > .agents/skills/adhere/SKILL.md`
540
- works without a checkout.
802
+ Two skills teach an agent to work with adhere:
803
+
804
+ - [`skills/adhere/SKILL.md`](./skills/adhere/SKILL.md) gathers a repo's
805
+ conventions into rule files, configures adhere, and calibrates thresholds.
806
+ - [`skills/adhere-fix/SKILL.md`](./skills/adhere-fix/SKILL.md) verifies and
807
+ fixes the findings `lint` reports: it checks each against its rule and the
808
+ code, fixes the real ones, reports the false ones with the evidence, and says
809
+ when a rule is wrong more often than right.
810
+
811
+ Install them with the [skills](https://github.com/vercel-labs/skills) CLI:
812
+ `skills add darkmatter/adhere`. The binary carries both: `adhere skill` prints
813
+ the first and `adhere skill fix` the second, so
814
+ `adhere skill fix > .agents/skills/adhere-fix/SKILL.md` works without a
815
+ checkout.
816
+
817
+ ### In CI
818
+
819
+ Pipe the fix skill into an agent that runs without asking. The skill tells it
820
+ that invoking the skill permits every `adhere lint` run and its cost, and
821
+ `adhere skill fix` prints the skill of the adhere that runs, so its flags
822
+ match:
823
+
824
+ ```sh
825
+ adhere skill fix | codex exec --yolo
826
+ adhere skill fix | claude -p --dangerously-skip-permissions
827
+ ```
828
+
829
+ Both turn off the agent's own permission checks, which suits a runner thrown
830
+ away after the job. To keep Codex in its sandbox, let the sandbox reach
831
+ `api.typesafe.ai`:
832
+
833
+ ```sh
834
+ adhere skill fix | codex exec --sandbox workspace-write \
835
+ -c sandbox_workspace_write.network_access=true -c approval_policy=never
836
+ ```
837
+
838
+ - Set `TYPESAFE_API_KEY` from a secret.
839
+ - The agent adds no `adhere-ignore` comments or `@adhere` notes unless
840
+ `ADHERE_ALLOW_SUPPRESSIONS=1` is set in its environment, as in
841
+ `adhere skill fix | ADHERE_ALLOW_SUPPRESSIONS=1 codex exec --yolo`. It then
842
+ suppresses a finding it shows is false, or that three fixes did not clear,
843
+ with the evidence in the comment. Without it, the agent leaves those
844
+ findings, and its report ends by naming them and the variable.
845
+ - End the job with `adhere lint --yes`. The agent exits 0 whatever it left,
846
+ so this step passes or fails the job; it judges only the files the agent
847
+ changed, and reads the rest from the cache.
848
+ - Do not run it on pull requests from forks. The agent reads their code with
849
+ your secrets in reach and the network open, so code written to steer it can
850
+ send them out.
541
851
 
542
852
  ## Development
543
853
 
@@ -556,7 +866,7 @@ and `bun run test` runs the tests.
556
866
  ### Native executable
557
867
 
558
868
  `bun run build` compiles `dist/adhere`, a single binary with Bun and the
559
- `effect` preset inside it, through Bun's
869
+ presets inside it, through Bun's
560
870
  [`--compile`](https://bun.sh/docs/bundler/executables) with
561
871
  `--asset ./presets`. It runs without Bun or `node_modules` on the target
562
872
  machine and still loads the repo's `config.ts` and Markdown rules from disk.
@@ -564,6 +874,12 @@ machine and still loads the repo's `config.ts` and Markdown rules from disk.
564
874
  into its package under `dist/npm/`
565
875
  ([`scripts/npm-packages.ts`](./scripts/npm-packages.ts)).
566
876
 
877
+ `adhere --version` tells the builds apart. A release prints its version, as
878
+ `adhere v0.10.0`. `bun run build` prints `git describe --tags --dirty` where
879
+ it was built, as `adhere v0.10.0-16-g9a41328`: 16 commits after v0.10.0, at
880
+ `9a41328`, ending in `-dirty` when the tree had changes. Run from source, it
881
+ prints the checkout's version marked `(source)`.
882
+
567
883
  Each platform's executable is its own npm package,
568
884
  `@drkmttr/adhere-<platform>-<arch>`, limited by `os` and `cpu`, and an
569
885
  optional dependency of `@drkmttr/adhere`, so an install fetches only its own