@drkmttr/adhere
v0.15.0
Published
Lints a repository against the rules in .adhere/, judged by Jev.
Readme
A linter for rules a normal linter cannot check, such as "Business logic should live in services", or "modules should expose lots of functionality behind a small API". Define rules as formatted markdown:
---
description: A third party API going offline should not also take our app down.
---
Our APIs should never be coupled to some third party API on a critical path. The user facing
side should be unaffected if it goes offline, and ingestion should happen in a separate asynchronous process.
## Should
```ts
import googleMaps from "./lib/maps"
import db from "./lib/db"
import redis from "redis"
import cron from "node-cron"
// asume this is called by /api/restaurants/:zipcode
export async function findRestaurants(zipCode: number) {
const results = db.restaurants.find({ where: { zipCode }})
return results
}
export async startWorker() {
cron("0 0 * * *", async () => {
const since = Number(await redis.get("maps:sync:timestamp"))
const items = await googlemaps.findAll({ since, limit: 100 })
const syncItem = item => db.restaurants.upsert(item)
const next = items.sort((a, b) => b.id < a.id).at(-1)?.timestamp
const touch = () => redis.set("maps:sync:timestamp", next ?? Date.now())
return Promise.all(items.map(item => syncItem(item))).then(touch)
})
}
```
## Never
```ts
import googleMaps from "./lib/maps";
import db from "./lib/db";
// asume this is called by /api/restaurants/:zipcode
export async function findRestaurants(zipCode: number) {
// what happens when google maps goes down? how about latency? how about costs?
const results = await googleMaps.findAll({ where: { zipCode }, limit: 100 });
return results;
}
```Your rules are parsed, then adhere asks Jev (TypeSafe AI's
System One model) whether the file breaks each rule, gets a calibrated
probability per rule, and reports the ones above a threshold with the section of
the file Jev points at. The report uses the same frame as vp lint.
Here's a demo of what the output looks like.
Presets for TypeScript, React, security, Effect, and alchemy are included, and run without setup:
TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset typescriptRules a normal linter can check exactly, such as a banned import or a type
error, belong in that linter. While it lints, adhere asks Jev whether a normal
linter could check each of your rules. adhere validate lists the
ones it thinks belong in a regular linter, and detects contradictions between
your rules.
Contents
- Install
- Quick start
- Generate rules from your repo
- Install rules from other repos
- Check for contradictions
- Live evals
- Writing good rules
- Commands
- Reading the report
- Rule format
- Config
- Running lint
- Comments and suppressions
- Cache
- Agent skills
- How a file is judged
- Upgrading
- Development
- License
Install
curl -fsSL --create-dirs -o ~/.local/bin/adhere \
https://github.com/darkmatter/adhere/releases/latest/download/adhere-$(uname -s)-$(uname -m)
chmod +x ~/.local/bin/adhereadhereis a single executable for macOS and Linux (arm64 and x64) and for Windows (x64), and needs nothing else installed, Node included. Any directory on yourPATHworks in place of~/.local/bin. On Windows, downloadadhere-Windows-x86_64.exefrom the latest release.- Run the command again to update.
download/v0.14.0in place oflatest/downloadfetches that version. - To pin the version in a JavaScript repo, for CI or scripts, add
@drkmttr/adhereas a dev dependency and runnpx adhere. Thatadherestarts through Node, or through Bun underbunx. - adhere sends each file it judges to Jev at
api.typesafe.ai, authenticated with a TypeSafe AI API key.
Login
adhere login # prompts for the key, masking what you type
adhere login < key.txt # reads it from stdin instead
adhere logout # deletes the saved key- The key is saved to
~/.config/adhere/credentials.json, or under$XDG_CONFIG_HOMEwhen that is set, readable only by you. TYPESAFE_API_KEY, when set, takes precedence over the saved key, so CI can pass a key without a login.- The key is read only when a request is about to be sent. A run where every file is cached needs no key and no network.
Quick start
adhere init # .adhere/config.ts, two example rules, and the dependency
adhere login # save your TypeSafe AI API key, once
adhere validate # check your rules' wording, and ask Jev whether any contradict
adhere lint # audit the working directory
git add .adhere # commit the rules, and the judgments they costInit
adhere init creates a config file, some example rules, and adds adhere as a
dev dependency.
It is safe to rerun: existing files are reported as skipped, and only --force
overwrites them.
Generate rules from your repo
Let an agent write your first rules with you. The
adhere-setup skill walks it through setting
adhere up in your repo:
- It reads where your conventions live:
AGENTS.md,CONTRIBUTING.md, docs and ADRs, your lint config, recent review comments, and the code others copy from. - It drafts the conventions a normal linter cannot check as rules, each with real code from your repo, and sets aside the ones a linter could check.
- It adds the presets that fit your stack, and rules from your organization's shared repo, if you have one.
- It shows every candidate in one list, with its evidence, and imports the ones you choose.
- It validates them, walks you through the first lint and what it costs, and adds adhere to CI.
It stops to ask you at each of those decisions, and never asks for your API key.
skills add darkmatter/adhere # install adhere's skills for your agentThen ask your agent to set adhere up.
Install rules from other repos
An organization's rules can live in one repo's .adhere/rules/, and other repos
copy them in with adhere install:
adhere list darkmatter/standards # each rule there, with its description
adhere install darkmatter/standards # every rule
adhere install darkmatter/standards/data # the data topic
adhere install darkmatter/standards/data/brand-ports # one rule
adhere install darkmatter/standards#v3 # every rule, as tagged v3- Copies land in this repo's
.adhere/rules/org/repo/. Sodata/brand-portsfrom darkmatter/standards is.adhere/rules/darkmatter/standards/data/brand-ports/, the ruledarkmatter/standards/data/brand-ports, and two repos' rules never collide. - The copies are the repo's own rules from then on: commit them, edit them, or start new rules from them. Nothing tracks where they came from.
- A later install skips a rule already there, as init does.
--forcereplaces its directory, edits and all. - A rule's whole directory is copied. The source's config, its inline rules, and its cache are not.
Read what you install. A
RULE.tsis copied with the helpers beside it, and runs whenever lint does, in CI too.adhere listnames aRULE.tswithout its description, since reading it would run it.
The source is cloned with git from https://github.com/org/repo.git, so a
private repo needs git's credentials for GitHub, as gh auth setup-git sets up.
To clone over SSH instead, let git rewrite the URL:
git config --global [email protected]:.insteadOf https://github.com/.
Creating a shared repo
adhere init --shared org/repo scaffolds a repo whose rules other repos copy in
with adhere install, instead of one that lints itself. It writes the two
example rules and no config, since install copies only rule files, and with
them:
| File | What it is for |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| README.md | Says what the repo is for, how to install its rules, and how to add one. |
| .github/workflows/adhere.yaml | Runs adhere validate on every push to main and every pull request, with the TYPESAFE_API_KEY secret. A pull request from a fork gets no secrets, so validate refuses there. |
| alchemy.run.ts | An alchemy stack that creates the GitHub repo, or adopts it, and sets that secret from TYPESAFE_API_KEY when deployed with npx alchemy deploy. |
| package.json, .gitignore | alchemy and effect, pinned to versions that work together, which init installs, and a .gitignore for them and alchemy's state. |
The repo is public unless you answer yes when init asks, at a terminal, whether
to make it private. Other repos then need git's credentials for it to list and
install its rules. Without a terminal it is public: change visibility in the
stack to make it private.
Check for contradictions
Two rules contradict when no code can follow both, such as one that says errors
must be thrown and one that says they must be returned. Each rule reads fine on
its own, so a contradiction shows up only as code that breaks one rule or the
other whatever you do. adhere validate finds them before lint does:
adhere validate # your rules
adhere validate --preset effect # your rules beside a preset'sFound 1 contradiction among configured rules:
1. No code can follow both (0.91):
- errors/throw-tagged-errors (/repo/.adhere/rules/errors/throw-tagged-errors/RULE.md)
A function that fails must throw a tagged error, never return an error value.
- errors/return-results (/repo/packages/api/.adhere/rules/errors/return-results/RULE.md)
A function that fails must return a Result, never throw.
Resolve by editing one rule, narrowing a nested rule's scope, or using the same rule id when the nested rule is meant to shadow the root rule.It asks Jev about each pair of rules that apply to the same files, so it needs
the API key, as lint does. A contradiction fails it, with exit code 1, so it
can gate a pull request that adds a rule, as the workflow
adhere init --shared writes does.
It also runs two checks that are advice and never fail it:
- Wording: each rule worded otherwise than the rule writing tips recommend, with its file and how.
- The linter check: while
lintjudges a file against one of your rules, it also asks Jev whether a linter or type checker could decide the rule exactly, on 10 files per rule. A rule flagged on 7 or more of them probably belongs in a regular linter. One flagged on 3 to 6 reads differently from file to file, so its description should say more precisely what it applies to. Preset rules are left out.
Two rules with the same id are never compared: a nested rule that shares an id shadows the other on purpose.
Live evals
Try a change to a rule on your own code before you rely on it: run the current rule and the changed one side by side, and compare what each flags.
Copy the rule's directory under a new id:
cp -r .adhere/rules/data/brand-ports .adhere/rules/data/brand-ports-nextGive the copy
level: warningin its front matter. Its findings show in amber and never fail the run, so the experiment can sit in the repo, and in CI, while you watch it.Change the copy: its description, its examples, its
appliesToorexcludeIf, or what itreads. To try giving Jev more to read, make the copy aRULE.tswith anappendStatehook.Run
adhere lint, oradhere lint --filter '<glob>'to try it on part of the repo first, and compare the two rules' findings on the same files.
Judge the two by recall, the share of real violations each catches, and
precision, the share of its flags that are real. Check the findings by hand, or
with the adhere-fix skill, which verifies each
one against its rule and the code. Then keep the better version under the
original id, and delete the other.
- The copy costs nothing until you change it. Judgments are cached by the rule's text, not by its id, its level, or its threshold, so a copy that reads the same as the original answers from the original's cache. Each change after that judges the copy, and only the copy, once per file.
- Keeping the winner is free. Moving the winning text to the original id reuses its cached judgments.
- Counting each rule's findings takes a pipe:
adhere lint --yes | grep -c 'data/brand-ports-next:'.
Writing good rules
We've evaluated how Jev reads a rule, on the presets and on other repos, to catch the most violations with the fewest false positives. The eval has every study; these are the lessons for writing a rule.
Rule writing tips
- Say "must" and "never", in the description and in the headings over the code, or "should" and "should not" for a guideline. Rewording the presets' descriptions this way, with a bad example per rule, was the largest gain of any study: at 0.8, as many caught, and 1 wrong where there had been 5 or 6 (study).
- Give one example of each kind: one
mustblock and oneneverblock. A bad example helped every wording, and at 0.9 caught 24 violations with none wrong, where there had been 17 (study). Either kind alone did worse, and three of each did no better, for 1.58 times the tokens (study).
adhere validate lists each rule worded otherwise.
What else the studies found
| Lesson | What the study found |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| "Should" is for people, not a weaker check. | Rules written with "should" and with "must" scored nearly the same (study). |
| A wrong finding is usually the rule's fault. | Above 0.8, 31 of 35 wrong findings repeated within a rule: a scope its words reach past, or the same misreading again. Fixing the wording fixes them together. Below 0.8, one-off misreads grow, to about one finding in six at 0.6 to 0.7 (study). |
| Narrow a rule in its description. | Jev scores an excludeIf near 0.5 for real and false findings alike, so it thins a rule's findings about as much as a higher threshold (study). Narrowing the description moves the judgment itself: scoping alchemy's idempotent-delete rule to providers' delete handlers dropped its findings elsewhere from 0.67–0.88 to 0.08–0.21, with nothing real lost (study). |
| Beware rules that turn on what the file does not show. | The weakest preset rules hinged on facts outside the file: whether a client has a default timeout, whether a script is an entry point, whether a key is public (study). Warning below a context of 0.7 marked one finding in five, and those were real about half the time, where the rest were real three times in four (study). Make such a rule a warning, or give Jev the fact with reads, an appendState hook, or an @adhere note. |
| Jev believes comments. | Eight of nine real violations of the idempotent-delete rule carried a comment wrongly saying they were fine, and scored 0.31 to 0.62; without comments, all nine scored 0.81 or more (study). That is why adhere takes comments out. A fact Jev needs, such as an exemption, goes in an @adhere note, which stays (study). |
Commands
| Command | What it does |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| adhere lint | Audit the working directory. See Running lint. |
| adhere validate | Check the rules' wording, and ask Jev whether any contradict. See Check for contradictions. |
| adhere init [--force] | Scaffold .adhere/config.ts and two example rules. See Init. |
| adhere init --shared org/repo | Scaffold a repo of rules other repos install. See Creating a shared repo. |
| adhere list org/repo | List the rules in another repo's .adhere/rules/. See Install rules from other repos. |
| adhere install org/repo | Copy them into this repo's .adhere/rules/org/repo/. |
| adhere login, adhere logout | Save or delete a TypeSafe AI API key. See Login. |
| adhere skill [docs\|setup\|fix] | Print an agent skill. See Agent skills. |
Bare adhere prints the help. adhere <command> --help lists a command's
flags, and adhere --completions <shell> prints a completion script.
Reading the report
A finding looks like this:
× data/brand-ports: A port must be a branded, range-checked integer, never a bare number.
confidence 0.93 · context 0.88
╭─[src/server.ts:6:3]
1 │ import { Effect } from "effect";
2 │ import { listen } from "./listen.ts";
3 │
4 │ export const serve = Effect.gen(function* () {
5 │ const host = process.env.HOST ?? "localhost";
6 │ const port: number = Number(process.env.PORT ?? 3000);
· ──────────────────────────────────────────────────────
7 │ yield* listen({ host, port });
8 │ yield* Effect.log(`Listening on ${host}:${port}`);
9 │ });
╰────
hint: const Port = Schema.Int.pipe(
Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
Schema.brand("Port"),
);
confidence 0–0.74 < 0.8 < 0.85–1 Jev's probability that the file breaks the rule
context 0–0.49 < 0.6 < 0.70–1 its probability that the file shows enough to decide
Found 1 error.
42 files, 3 judged, 39 cached.| Part | What it tells you |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| × or ⚠ | An error, or a warning, in amber, from a rule whose level is warning. Only errors fail the run. |
| data/brand-ports | The rule's id, then its description. A preset's rule has the preset's name first, as in effect/basics/gen-for-sequencing. |
| confidence | Jev's probability that the file breaks the rule. |
| context | Jev's probability that the file shows enough to decide that. |
| The excerpt | The section of the file Jev points at. The line it names is underlined, or the lines, when it names several, are marked with a bar beside the code. |
| hint: | The rule's code that must be written. A rule with only code that must never be written shows that code instead, labeled never:. |
| The legend | Under the last finding: each score's threshold, between its range in red and its range in green. |
Score colors. On a terminal, each score is colored by where it sits around its threshold:
- If you're near the threshold (orange), consider adjustments.
- Red is below the threshold, so lint never reports it: only the legend shows that range.
Low context. Jev sees one file at a time. So we also ask whether the file
shows enough to decide the rule at all. When context is below 0.6, it means
Jev thinks it needs more info, which you can add with an @adhere comment.
You may also see a hint:
warning: this file may not show enough to check this rule
help: add what the code relies on outside this file as a note Jev reads:
// @adhere <the fact>, and how you know it- On the eval, most findings with this warning were false, where about one in five of all findings was (study). Check what the code relies on outside the file before acting on it.
- If you feel the threshold is too low/high, override it using
sufficiencyThresholdin the config.
Rule format
A repo's own rules live in .adhere/rules/, organized just like skills:
.adhere/
config.ts optional
cache/ commit it
rules/
data/ a topic: a directory that holds no rule
brand-ports/
RULE.md the rule data/brand-ports
columns/
RULE.ts a rule written in TypeScript
schema.ts a helper it imports, not read as a rule- The directory's path is the rule id:
.adhere/rules/data/brand-ports/RULE.mdisdata/brand-ports. - A rule can be markdown
RULE.md, or typescriptRULE.ts. A directory with both refuses the run. - Files not named
RULEare ignored.
Rules as Markdown files
A RULE.md is front matter, then a body that holds the rule's code:
---
description: A port must be a branded, range-checked integer, never a bare number.
threshold: 0.8
---
Why: a bare `number` accepts 70000 and -1.
## Must
```ts
const Port = Schema.Int.pipe(
Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
Schema.brand("Port"),
);
```
## Never
```ts
const port: number = Number(process.env.PORT);
```| Front matter | Meaning |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| description | Required. One sentence saying what code must be, and never be. |
| threshold | The rule's own cutoff, in place of the config's. |
| level | warning reports the rule's findings as warnings, which do not fail the run. Use it for a nit, or for a rule that tends to flag code wrongly. |
| tests | Rules skip tests by default. only is for a rule about tests, which judges nothing else. include is for one that holds in tests as well. |
| appliesTo, excludeIf | Where the rule applies. See Scoping a rule. |
| reads | What Jev reads beside the code. See What a rule reads. |
| Heading in the body | The code under it |
| ---------------------------- | ----------------------------------------------------------- |
| ## Must | must be written |
| ## Never | must never be written, which is what a violation looks like |
| ## Should, ## Should not | the same, for a guideline |
- A rule needs code under at least one heading.
- A rule is a requirement ("must", "never") or a guideline ("should", "should not"), and cannot mix the two.
- A rule with only a
neverblock suits a rule with no single correct form to show, such as a hand-rolled retry loop or an error caught and dropped. - Neither goes in the other's place: code that must never be written, under
must, reads to Jev as the pattern to follow. - Prose around the code is the rule's details: Jev reads it after the description, so use it to say why the rule holds or where it does not apply. The report shows the description alone.
- A heading names the code in its section, which runs to the next heading at its
level or higher, so a deeper heading such as
### A bare numberstays inside it. - Only a heading that is the word alone names code, in any case and with or
without a colon:
## Never:does,## Never do thisdoes not. - A fence can instead name its code after its language, as in
ts never, which GitHub does not show. A fence's own word wins over its heading's. - A rule inline in a config takes the same keys, with its code under
must,never,should, andshouldNot, and its prose underdetails. loadRules(directory)from the package root does the same load for your own tooling.
Scoping a rule
appliesTo and excludeIf say where a rule applies, apart from what it asks
for. Each is a list of descriptions of code: a JSON array on one line in front
matter, or an array in a config or a TypeScript rule.
---
description: Structured variants must be Schema.TaggedClass members of a Schema.Union, never hand-written object types joined by a tag field.
appliesTo: ["a declared union type or schema"]
excludeIf: ["a type that mirrors a third-party format whose tag key that format fixes, such as a Slack Block Kit block"]
---| Key | Jev is asked, of the code that breaks the rule | A finding stands when |
| ----------- | ---------------------------------------------- | ------------------------- |
| appliesTo | whether any of it is as described | Jev says yes to every one |
| excludeIf | whether all of it is as described | Jev says yes to none |
The questions are about the code that breaks the rule, not the whole file, so a
file with a real violation beside code an excludeIf describes keeps its
finding.
A yes is a probability above 0.5. At --log-level debug, lint logs each
finding its matchers dropped, with their scores.
Narrowing the description often works better. In the preset study, Jev scored an
excludeIfnear 0.5 for real and false findings alike. See Writing good rules.
What a rule reads
Jev judges a file by its code alone. A rule that turns on something the file
does not show can name it in reads, and adhere gives it to Jev beside the
code, for that rule only. It is a JSON array on one line in front matter, or an
array in a config or a TypeScript rule.
---
description: A function from one of this repository's own packages, which `workspacePackages` names, must be called through the package's namespace, never imported by its own name.
reads: ["workspacePackages"]
---
## Must
```ts
import * as Orders from "orders-core";
```
## Never
```ts
import { parse } from "orders-core";
```There is one name so far:
| Name | What Jev reads |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| workspacePackages | The workspace's packages, each one's name with its directory, as in { "orders-core": "packages/orders" }. A package that is not in it is an installed one. It is for a rule about whose package an import is of: the repository's own, or one installed from a registry. |
- Each name is the key Jev reads it under, so the description can name it in
backticks, as the questions name
code. - A name adhere does not have refuses the run, so a misspelled one does not go unnoticed.
- What adhere has no name for, such as a file of the repository's, a rule in
TypeScript adds with
appendState.
- The packages are read on every run, when a rule reads them, from the working
directory's
package.json, whoseworkspacesnpm, Bun, and Yarn read, and frompnpm-workspace.yaml'spackages. - In a pattern,
*is any directory and**any depth of them, and one that starts with!leaves out what it matches. Each matched directory'spackage.jsongives the name, and its directory is given from the working directory. - Nothing need be installed, so a run in CI reads the same packages as one on a laptop.
- A working directory that is not a workspace's root has none.
Rules as TypeScript files
A rule's directory can hold it as a RULE.ts instead, which default-exports
defineRule({...}), taking the fields a config's inline rule does. What
TypeScript adds is appendState, a hook on what Jev reads for the rule:
// .adhere/rules/data/columns/RULE.ts
import { defineRule } from "@drkmttr/adhere";
export default defineRule({
description:
"A query must name only columns its table has in `schema`, and never a column it lacks.",
must: 'db.select("id", "email").from("users")',
never: 'db.select("mail").from("users")',
appendState: async (state, file, Bun) => ({
schema: await Bun.file("db/schema.sql").text(),
}),
});- The hook runs right before each request about the rule goes out. It gets the request's state, the file the request is about, and Bun's API, and what it returns is spread over the state.
- The description can name a key the hook adds, in backticks, as the questions
name
code. - A
RULE.tsis imported, as a config is, so its code runs on every lint. - A rule with a hook goes to Jev in requests of its own, so only that rule reads what its hook adds. Each of those requests sends the file again.
- What the hook reads outside the file is not part of the cache: when
db/schema.sqlchanges, a file already judged is not judged again until it or the rule changes.
- The state is what Jev reads beside each question:
code, the file's sections as Jev reads them, under their numbers from 1, as in{ code: { "1": "import …", "2": "export const …" } }. fileis the file's absolutepath, and itscontentsas written, comments and all.- Nothing the hook returns is checked, so it can change
codetoo, or replace it: a hook can break its own rule's answers, and adhere does not stop it. - It can be async, and one that throws refuses the run, naming the rule. A
RULE.tsthat default-exports no rule refuses the run too. - The plan counts the rule's requests, but not the tokens a hook adds, which are not known until it runs. A request over Jev's context skips the file, as a file too long does.
- A
RULE.tscan import other files by relative path, such as a helper beside it in its directory, but the executable resolves no packages besides@drkmttr/adhere. Bunis typed when@types/bunis installed and.adhere/tsconfig.jsonlists it, as"types": ["bun"], and isunknownotherwise.- A config's inline rules can have
appendStatetoo.
Where rules live
- Rules in the root
.adhere/rules/apply project-wide. - A nested
.adhere/scopes its rules to the directory that contains it. A rule inpackages/api/.adhere/rules/data/brand-ports/RULE.mdhas the same id,data/brand-ports, but applies only to files underpackages/api/. - If a nested rule has the same id as a root rule, the nearest containing
.adhere/shadows the less-specific rule for that subtree. Outside that subtree, the root rule still applies. - Nested
.adhere/directories are not discovered undernode_modules/,dist/, and the other skipped directories.
A repo that would rather keep its rules with the rest of its documentation can
point rules at a directory such as docs/adhere/:
export default defineConfig({ presets: ["effect"], rules: "./docs/adhere" });That directory holds its rules as .adhere/rules/ does, a directory per rule,
and they apply project-wide.
Config
A config file is optional. Without one, adhere reads the rules in
.adhere/rules/ and any --preset. A config names presets, sets the model and
thresholds, or gives rules inline. It sits in the working directory of the repo
being audited, at one of these paths (keep one):
.adhere/config.ts, the default, next to the rulesadhere.config.ts.adhere.config.ts
import { defineConfig } from "@drkmttr/adhere";
export default defineConfig({
model: "jev-latest",
threshold: 0.8,
sufficiencyThreshold: 0.6,
presets: ["effect"],
exclude: ["**/generated/**"],
overrides: { "effect/basics/instrument-with-pipe": "off" },
rules: {
"data/brand-meaningful-primitives": {
description:
"A primitive with semantic meaning, such as an id, email, URL, port, or count, must be a branded schema.",
must: `
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
`,
never: "type UserId = string",
threshold: 0.8,
},
},
});| Key | Default | Meaning |
| ---------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| model | "jev-latest" | The model id sent to TypeSafe. |
| threshold | 0.8 | Report a rule when Jev's probability is above this. |
| sufficiencyThreshold | 0.6 | Below it, a finding warns that the file may not show enough. |
| presets | none | Built-in rule sets, or their topics. See Presets. |
| rules | .adhere/rules/ | Rules inline, as above, or a directory (see Where rules live). Either replaces .adhere/rules/: none of its rules, root or nested, is read then. |
| exclude | none | Globs, relative to the working directory, of files no rule judges. |
| overrides | none | Settings for a rule by its id. See Overrides. |
| includeComments | false | Send every comment to Jev. See Comments. |
- An invalid shape refuses the run.
- A config can import other files by relative path, but no packages besides
@drkmttr/adhere: the executable does not resolvenode_modules.
defineConfigtypes the config for the editor and returns it as it is.satisfies Config, withimport type { Config }, does the same.- The executable supplies
@drkmttr/adhereto the config whether or not the repo has the package installed. The editor's types come from the package, whichadhere initadds as a dev dependency. - A tsconfig's globs skip dot directories, so the editor opens
.adhere/config.tsoutside any project, where it cannot resolve the package's types: they are reached only bybundler,node16, ornodenextresolution.adhere initwrites.adhere/tsconfig.json, a project for the config and any rules written in TypeScript, withbundlerresolution. For a config written by hand, add that file, or include.adhere/config.tsin a tsconfig that resolves the same way.
Presets
| Preset | Rules | For | What it asks for |
| ------------------------------------- | ----- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| typescript | 11 | any TypeScript project | Data from outside checked at runtime, invalid states unrepresentable, errors never swallowed, resources released on every path, arguments not mutated, and tests that assert outcomes and stand alone. |
| react | 10 | components and hooks | Logic for an event in its handler rather than an effect, effects that clean up and ignore stale fetches, useSyncExternalStore for outside stores, state that neither copies props nor contradicts itself, and Server Functions and Server Components that guard what crosses to the client. |
| security | 4 | any project | Secrets kept out of logs, error messages, and responses; parameterized SQL; no untrusted input in shell commands, eval, or file paths; and secrets compared in constant time. From OWASP's cheat sheets. |
| effect | 16 | code written with Effect | Steps sequenced with Effect.gen, instrumentation attached with .pipe, config read through a service and validated, secrets redacted, branded primitives and tagged unions, defects kept apart from typed errors, services built by layers, and tests with their own layers and TestClock. |
| alchemy | 41 | code that deploys with alchemy | Where Config and bindings are read, which resources keep their data, how secrets stay out of bundles and logs, authorization on public URLs, migrations, durable workflows, and custom providers. |
- Name presets in the config's
presets, or on the command line with--preset, repeated or separated by commas, as in--preset effect,alchemy, in which case the config file is optional. Presets named in both places apply together. - A topic, one of a preset's subdirectories, is a preset of its own:
--preset effect/basicsapplies only the rules underpresets/effect/basics/, andalchemy/secretsonly alchemy's secrets rules.securityhas no topics. - To change a preset's rule without copying it, use Overrides.
- It leaves out conventions a linter checks exactly. The notes on each preset say which linter checks them.
- A preset rule says "must" only where its source makes a requirement, and "should" where the source gives advice.
- A topic's rules keep the ids they have in the whole preset, so a topic and its preset share cached judgments, and naming both applies each rule once.
- Where effect or alchemy had a rule that
typescriptorsecurityhas, that one is kept and the other is gone. typescript'sasync/network-calls-have-timeoutsreplaced effect'sbasics/external-calls-are-resilient, andsecurity/parameterized-queriesandsecurity/no-secrets-in-outputreplaced alchemy'sdata/parameterized-sqlandsecrets/never-logged-returned-or-output. A project on Effect or alchemy namestypescriptandsecuritytoo, for those rules.
- Source. The TypeScript Handbook and the Google TypeScript Style Guide.
- Left to a linter. What typescript-eslint and ESLint check exactly:
unhandled promises (
no-floating-promises), throwing non-errors (only-throw-error),any(no-explicit-any), exhaustive switches (switch-exhaustiveness-check), a lostcause(preserve-caught-error), and empty catch blocks (no-empty). - Warnings. Four of its rules report warnings. Three are advice that code often has reason to set aside: assertions that claim only what the code established, types derived rather than restated, and independent awaits run concurrently. The fourth, that HTTP requests carry a timeout, was right on fewer than half its findings above 0.8 on the eval: many of the rest were calls through a client with a default timeout that the file does not show.
- Tests.
testing/assert-outcomesandtesting/independent-testssaytests: only: they judge tests and nothing else. - Gone. Two rules the eval found mostly wrong: that tests use fake time rather than real waits, and that importing a module does no work.
- Source. react.dev, mostly You Might Not Need an Effect and Choosing the State Structure.
- Left to a linter. What the React Compiler's lint rules check, in
eslint-plugin-react-hooks's
recommended set and in Oxlint: pure render (
purity), props and state never mutated (immutability), refs not read during render (refs), nosetStatein render or synchronously in an effect (set-state-in-render,set-state-in-effect), which covers state derived in an effect, and no component defined inside another (static-components). - Files. Its rules judge
.tsfiles as well as.tsx, and apply only to components, hooks, effects, and Server Functions, so other files cost their checks and find nothing. - Warnings.
server/no-private-data-to-clientreports warnings: a file does not show whether the component it passes a record to is a Client Component. So doesstate/store-ids-not-copies, which also flags state that copies a string or an option from a fixed list, where a copy cannot go stale.
Source. The effect-solutions docs and the effect/platform docs.
Tests. Three rules say
tests: only, so they judge tests and nothing else:testing/test-clock-for-time,services/fresh-layer-per-test, andconfig/tests-provide-values-directly.Guidelines. One rule says "should": a test of code that consumes config provides it through a layer.
Domain types. The data rules judge the types services exchange, return, or store. A shape that mirrors a payload until it is mapped, and a view's props or state, are out of their scope.
What the rules allow. Config validation accepts
Config.mapEffectas well asConfig.schema, and the variants rule does not rule out aswitch.Gone. The rule that a command handler only parses input: the docs show that pattern but do not ask for it. The rule that test layers are in memory, which flagged tests of real services: none of its findings labeled on the eval or on darkmatter/agents was real. And the rule that service operations have no requirements, which
leakingRequirementsbelow checks exactly.Left to a linter. Effect's language service,
@effect/tsgoon TypeScript 7, checks seven conventions exactly, which the preset checked through 0.7. Most are off by default.npx @effect/tsgo setupinstalls it, and these lines in its plugin options intsconfig.jsonturn them on:"diagnosticSeverity": { "strictEffectProvide": "warning", // layers are provided once, at the entry point "leakingRequirements": "warning", // service methods have no requirements "effectFnOpportunity": "warning", // a named function returning an Effect uses Effect.fn "preferSchemaOverJson": "warning", // JSON is decoded with Schema, not JSON.parse "nodeBuiltinImport": "warning", // platform services, not node: builtins "globalFetch": "warning", "globalFetchInEffect": "warning", "processEnv": "warning", "processEnvInEffect": "warning", "globalRandom": "warning", // the Random service, not Math.random "globalRandomInEffect": "warning", "globalErrorInEffectFailure": "warning", // tagged errors, not Error "extendsNativeError": "warning" }npx @effect/tsgo diagnostics --project tsconfig.jsonruns them in CI. Bun globals, which the old platform rule also ruled out, need a lint rule of their own, such asno-restricted-globals.What the linter misses.
leakingRequirementssees a requirement in an operation's type, but not a service built by a factory function that takes its dependencies as arguments, whose types have no requirements to find. The preset'sservices/dependencies-through-layersasks for that.
- Source. alchemy's docs and blog.
- Warnings.
providers/idempotent-deletereports warnings. Whether a delete fails on a resource that is already gone is a fact about the API, which the file does not show, so the rule also flags deletes of APIs that succeed anyway. See the study.
Overrides
A config's overrides changes a rule by the id a report names it with, a
preset's with the preset first, without copying its text into rules:
export default defineConfig({
presets: ["effect", "alchemy"],
overrides: {
"alchemy/providers/idempotent-delete": { level: "warning", threshold: 0.9 },
"effect/basics/instrument-with-pipe": "off",
},
});| Value | Effect |
| ---------------------- | -------------------- |
| "off" | drops the rule |
| "warning", "error" | sets its level |
| { level, threshold } | sets either, or both |
An id that no preset or project rule has refuses the run, so a typo does not go unnoticed. A rule of a preset the run does not name is accepted.
Precedence
For the model and the threshold, highest first:
--thresholdon the command line- the config file
- presets, in order (a later preset wins)
- the defaults,
jev-latestand0.8
For one rule:
- A rule's own
thresholdbeats all of the above for that rule. - An entry in
overridesbeats the rule's ownthresholdandlevel. - A rule in
rulesreplaces a preset rule with the same id.
--sufficiency-threshold beats the config's sufficiencyThreshold, which beats
the default, 0.6.
Running lint
Flags
| Flag | What it does |
| ---------------------------------- | ---------------------------------------------------------------------------------------------- |
| --preset <names> | Add built-in rule sets, or their topics. The config becomes optional. See Presets. |
| --threshold <0 to 1> | Replace the config's threshold. Per-rule thresholds still apply. |
| --sufficiency-threshold <0 to 1> | Replace the config's sufficiencyThreshold: a finding warns when its context is below it. |
| --yes, -y | Send the requests without asking first. |
| --limit <checks> | Judge at most that many checks. See Limiting a run. |
| --rpm <requests> | Send at most that many requests a minute. |
| --filter <glob> | Read only the files a glob matches. |
| --deny-warnings | Fail on warnings as well as errors. |
| --log-level <level> | Log the run's work on stderr. See Logging a run. |
What gets read
adhere lintreads the TypeScript files under the working directory:.ts,.tsx,.mts, and.cts. It does not read declaration files such as.d.ts, adhere's own config files, or JavaScript.When the working directory contains
agents/,apps/, orpackages/, only those trees are read.Below the working directory, anything under
node_modules/,dist/,coverage/,vendor/,e2e/,references/,.adhere/,.agents/,.claude/,.direnv/,.alchemy/, or.vite/is skipped. The directories above the working directory do not count..gitignoreis not consulted.A config's
excludelists globs, relative to the working directory, of files no rule judges, such as generated code. It leaves them out of every run, as--filter '!<glob>'leaves them out of one:export default defineConfig({ exclude: ["**/generated/**"] });
Tests are judged only by rules about tests, whose front matter says tests
(see Rules as Markdown files). On alchemy, 38% of
the findings from rules not about tests were in test helpers and fixtures. A
test is:
- a
.testor.specfile, such asa.test.tsorButton.spec.tsx; - a fixture or test helper named as one, such as
fixtures.ts,GitHubHttpFixtures.ts,ledger-copy.fixture.ts,linear-test-helpers.ts, ortest-utils.ts; - any file under a
test/,tests/,__tests__/,testing/,fixtures/,fixture/,__fixtures__/, or__mocks__/directory.
Before and during a run
Before it judges anything, lint says on stderr what it found and what judging
takes, and asks:
200 files and 14 rules: 2800 checks, 1400 cached.
Judging the other 1400 takes 200 requests to Jev, plus 1 or more for each file with a finding.
Those carry about 1.9 million input tokens: about $0.08 at $0.042 per million, and more for locating findings.
? Send 200 requests to Jev, about $0.08? › (Y/n)- It asks only with a terminal on stdin and stdout, and never when the cache
answers every check.
--yessends without asking. - The cost is adhere's estimate of the input tokens times the model's price. Jev
charges only for input tokens: $0.042 a million for
jev-latest, per TypeSafe AI's models page in September 2026. - A run that stops loses nothing: files finished before it stopped are cached, so running again continues from there.
- For a model adhere has no price for, the plan gives the tokens alone.
- The firewall in front of Jev's API can refuse a request whose code reads to it as an attack, with a 403 and an HTML page rather than Jev's JSON. It refuses the same request every run, so that file is skipped, the run goes on, and the report lists each such file with Cloudflare's Ray ID, which TypeSafe AI can look the block up by.
- When only the question that finds the line is refused, the file's judgments are kept, and a rerun sends that question alone.
Limiting a run
Three flags bound what a run does:
| Flag | Effect |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --limit <checks> | Judges at most that many checks, taken in path order. The rest wait, and since judgments are cached, the next run with the same limit picks up where this one stopped. --limit 0 shows the plan and judges nothing. |
| --rpm <requests> | Sends at most that many requests to Jev a minute, evenly spaced, retries included. The plan says about how long they take. |
| --filter <glob> | Reads only the files whose path from the working directory matches, such as src/** or **/*.service.ts. Wrap a glob in single quotes so the shell does not expand it. Repeat the flag for more, and start a pattern with ! to leave out what it matches. |
adhere lint --filter 'packages/api/**' --filter '!**/generated/**' --limit 200 --rpm 30Logging a run
| Level | What it logs, on stderr |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| --log-level debug | The config it loaded, how many paths it listed and how many of them it reads, and each request to Jev, with the file it is for, about how many tokens it carries, the HTTP status, how long it took, and Cloudflare's Ray ID. A failed attempt is logged even when a retry hides it. |
| --log-level trace | Also each file as it is read, planned, and answered from the cache, and the first 4000 characters of any error Jev's API answers with. |
stdout still carries only the report, so the log can go to a file of its own:
adhere lint --log-level debug 2> adhere.logExit codes
| Code | When |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | Nothing is reported, or only warnings. |
| 1 | An error is reported. With --deny-warnings, a warning too. |
| 1 | The run refuses, for example on an invalid config or rule file, a missing API key, or an unknown command or flag. A refusal prints its reason. |
Comments and suppressions
Comments
Jev reads a file's code, not its comments. adhere takes every comment out before it hashes or sends a file, blanking it so every line keeps its number. The report's excerpts still show them.
Jev takes what comments claim at their word, which hid real violations in the studies: see Writing good rules.
A comment that says @adhere anywhere in it is a note for Jev, and stays. Write
one for a fact the code relies on that the file cannot show, once you have
checked it, with how you know:
/**
* Deletes the activity. @adhere DeleteActivity succeeds on a missing
* activity, so no not-found error needs catching; probed 2026-09-25.
*/- A note informs Jev. It suppresses nothing, and Jev still judges the code around it.
includeComments: truein the config sends every comment, for a project whose rules are about comments, such as doc comments on exports.
Suppressing a finding
A finding Jev got wrong is suppressed in the code, with a comment saying why:
// adhere-ignore alchemy/providers/idempotent-delete -- DeleteActivity succeeds on a missing activity; probed 2026-09-25
delete: Effect.fn(function* ({ output }) {
yield* sfn.deleteActivity({ activityArn: output.activityArn });
}),| Comment | What it covers |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| adhere-ignore, on a line of its own | The statement that starts on the next line of code: above, the whole handler, wherever in it Jev points, since the lines it points at can move between runs. |
| adhere-ignore, at the end of a line of code | The statement that starts on that line. |
| adhere-ignore-file, anywhere in a file | The whole file. The rules it names are not judged there at all. |
- A comment names rules as a report does, separated by commas.
- What follows
--is the reason, for whoever reads the code next. - A finding without lines is covered when its section overlaps the statement.
- Jev never reads these comments, even with
includeComments: true. - The summary counts the findings they suppress.
Cache
Judgments are cached in .adhere/cache/. Commit it. Every judgment is a
paid request, and Jev's answers vary a little from run to run, so a committed
cache gives everyone and CI the same findings without paying for them again, and
a pull request changes the judgments of only the files it changes.
| What changed | What is judged again |
| ------------------------------------------------------------ | ----------------------------------------------------- |
| A file's code | Every rule, for that file. |
| A file's comments alone | Nothing. |
| A file is moved or copied | Nothing. Identical files share their judgments. |
| A rule's text, a matcher, or the source of its appendState | That rule, for every file. |
| What a rule reads, such as the workspace's packages | That rule, for every file. |
| The threshold is lowered | Nothing. Cached judgments newly above it are located. |
| adhere asks its questions differently, after an upgrade | Every rule, once. |
To keep the cache out of diffs, mark it as generated in .gitattributes:
.adhere/cache/** linguist-generated -diffWhere lint gates a merge. Anyone who can commit can also write a cache file saying that code passes. Lint a pull request against the cache as merged, not as the pull request has it. Only the files it changes are judged again:
rm -rf .adhere/cache && git checkout origin/main -- .adhere/cache adhere lint --yes
files/holds Jev's answers by the content they are about. Each cache file is named for the hash of a source file's content as Jev reads it, without its comments.- Each answer sits under a fingerprint of the model and everything the rule
