@drkmttr/adhere 0.10.0 → 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.
- package/README.md +177 -41
- package/package.json +7 -7
- package/presets/effect/basics/gen-for-sequencing.md +1 -1
- package/presets/effect/data/brand-meaningful-primitives.md +1 -1
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/services/dependencies-through-layers.md +1 -1
- package/presets/react/effects/clean-up-what-they-start.md +23 -0
- package/presets/react/effects/event-logic-in-handlers.md +29 -0
- package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
- package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
- package/presets/react/effects/notify-parents-in-handlers.md +29 -0
- package/presets/react/server/no-private-data-to-client.md +23 -0
- package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
- package/presets/react/state/no-contradictory-state.md +17 -0
- package/presets/react/state/no-props-copied-into-state.md +23 -0
- package/presets/react/state/store-ids-not-copies.md +20 -0
- package/presets/security/constant-time-secret-compare.md +22 -0
- package/presets/security/no-secrets-in-output.md +19 -0
- package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
- package/presets/security/parameterized-queries.md +16 -0
- package/presets/typescript/async/concurrent-independent-work.md +23 -0
- package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
- package/presets/typescript/design/no-argument-mutation.md +20 -0
- package/presets/typescript/errors/no-swallowed-errors.md +29 -0
- package/presets/typescript/errors/release-on-every-path.md +29 -0
- package/presets/typescript/testing/assert-outcomes.md +22 -0
- package/presets/typescript/testing/independent-tests.md +41 -0
- package/presets/typescript/types/assertions-only-when-proven.md +20 -0
- package/presets/typescript/types/derive-dont-restate.md +24 -0
- package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
- package/presets/typescript/types/validate-external-data.md +21 -0
- package/skills/adhere/SKILL.md +4 -3
- package/skills/adhere-fix/SKILL.md +10 -4
- package/src/cli.ts +9 -1
- package/src/config.ts +29 -1
- package/src/excerpt.ts +72 -62
- package/src/highlight.ts +2 -0
- package/src/markdown.ts +6 -0
- package/src/presets.ts +6 -0
- package/src/rules.ts +1 -1
- package/src/services/AuditCache.ts +31 -5
- package/src/services/Jev.http.ts +76 -11
- package/src/services/Jev.ts +191 -73
- package/src/services/SourceWalker.ts +23 -7
- package/src/suppress.ts +13 -7
- package/src/version.ts +25 -1
- package/src/workflows/audit.ts +96 -26
- package/src/workflows/format.ts +51 -11
- package/presets/alchemy/data/parameterized-sql.md +0 -17
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
- package/presets/effect/basics/external-calls-are-resilient.md +0 -21
package/README.md
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
<h1>adhere</h1>
|
|
3
|
+
<sup>A linter for non-deterministic rules. Powered by Typesafe</sup>
|
|
3
4
|
</div>
|
|
4
5
|
|
|
5
|
-
A linter for rules a normal linter cannot check.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
9
12
|
model) whether the file breaks each rule, gets a calibrated probability per
|
|
10
|
-
rule, and reports the ones above a threshold with the
|
|
11
|
-
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
|
+
|
|
12
20
|
|
|
13
21
|
Rules a normal linter can check exactly, such as a banned import or a type
|
|
14
22
|
error, belong in that linter. While it lints, adhere asks Jev whether a normal
|
|
@@ -16,10 +24,10 @@ linter could check each of your rules, and [`adhere validate`](#validate) lists
|
|
|
16
24
|
rules that it thinks belong in a regular linter, and also detects any
|
|
17
25
|
contradictions in your rules.
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
Presets for TypeScript, React, security, Effect, and alchemy are included, and run without setup:
|
|
20
28
|
|
|
21
29
|
```sh
|
|
22
|
-
TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset
|
|
30
|
+
TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset typescript
|
|
23
31
|
```
|
|
24
32
|
|
|
25
33
|
## Install
|
|
@@ -54,6 +62,9 @@ built-in Effect rules. A finding looks like this:
|
|
|
54
62
|
```text
|
|
55
63
|
× data/brand-ports (0.93): A port must be a branded, range-checked integer, never a bare number.
|
|
56
64
|
╭─[src/server.ts:6:3]
|
|
65
|
+
1 │ import { Effect } from "effect";
|
|
66
|
+
2 │ import { listen } from "./listen.ts";
|
|
67
|
+
3 │
|
|
57
68
|
4 │ export const serve = Effect.gen(function* () {
|
|
58
69
|
5 │ const host = process.env.HOST ?? "localhost";
|
|
59
70
|
6 │ const port: number = Number(process.env.PORT ?? 3000);
|
|
@@ -73,12 +84,38 @@ Found 1 error.
|
|
|
73
84
|
|
|
74
85
|
The header is the rule id, Jev's probability, and the rule's description. A
|
|
75
86
|
preset's rule has the preset's name first, as in
|
|
76
|
-
`effect/basics/
|
|
87
|
+
`effect/basics/gen-for-sequencing`, so a report that mixes presets
|
|
77
88
|
with a repo's own rules says where each came from.
|
|
78
|
-
Under it is the
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
82
119
|
never be written shows that code, labeled `never:`, instead. A rule whose
|
|
83
120
|
level is `warning` reports its findings with `⚠` in place of `×`, in amber. On
|
|
84
121
|
a terminal, the report is in color and the code in it is highlighted. The exit
|
|
@@ -93,6 +130,7 @@ an unknown command or flag; a refusal prints its reason.
|
|
|
93
130
|
adhere lint # audit the working directory
|
|
94
131
|
adhere lint --preset effect # add a built-in rule set; the config becomes optional
|
|
95
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
|
|
96
134
|
adhere lint --yes # send the requests without asking first
|
|
97
135
|
adhere lint --limit 500 # judge at most 500 checks; the rest wait for the next run
|
|
98
136
|
adhere lint --rpm 30 # send at most 30 requests a minute
|
|
@@ -110,10 +148,6 @@ Bare `adhere` prints the help, which lists the commands, and
|
|
|
110
148
|
`adhere <command> --help` lists a command's flags. `adhere --completions <shell>`
|
|
111
149
|
prints a completion script.
|
|
112
150
|
|
|
113
|
-
### Demo
|
|
114
|
-
|
|
115
|
-
<a href="https://asciinema.org/a/1266511" target="_blank"><img width="400" src="https://asciinema.org/a/1266511.svg" /></a>
|
|
116
|
-
|
|
117
151
|
### Before and during a run
|
|
118
152
|
|
|
119
153
|
Until it knows what judging takes, a status line on stderr says what `lint` is
|
|
@@ -200,7 +234,8 @@ delete: Effect.fn(function* ({ output }) {
|
|
|
200
234
|
|
|
201
235
|
On a line of its own, `adhere-ignore` covers the statement that starts on the
|
|
202
236
|
next line of code: here the whole handler, wherever in it Jev points, since the
|
|
203
|
-
|
|
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
|
|
204
239
|
covers the statement that starts on that line. `adhere-ignore-file`, anywhere
|
|
205
240
|
in a file, covers the whole file, and the rules it names are not judged there
|
|
206
241
|
at all. A comment names rules as a report does, a preset's with its preset
|
|
@@ -226,17 +261,22 @@ adhere lint --log-level debug 2> adhere.log
|
|
|
226
261
|
|
|
227
262
|
### What gets read
|
|
228
263
|
|
|
229
|
-
`adhere lint` reads the
|
|
230
|
-
|
|
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
|
|
231
267
|
`packages/`, only those trees are read. Below the working directory, anything
|
|
232
268
|
under `node_modules/`, `dist/`, `coverage/`, `vendor/`, `e2e/`, `references/`,
|
|
233
269
|
`.adhere/`, `.agents/`, `.claude/`, `.direnv/`, `.alchemy/`, or `.vite/` is
|
|
234
|
-
skipped; the directories above it do not count.
|
|
235
|
-
`.gitignore` is not consulted.
|
|
236
|
-
|
|
237
|
-
Tests are judged only by rules about tests. A test is a `.test
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
240
280
|
one (see [Rules as Markdown files](#rules-as-markdown-files)). On alchemy, 38%
|
|
241
281
|
of the findings from rules not about tests were in test helpers and fixtures.
|
|
242
282
|
|
|
@@ -337,6 +377,7 @@ import { defineConfig } from "@drkmttr/adhere";
|
|
|
337
377
|
export default defineConfig({
|
|
338
378
|
model: "jev-latest", // optional, default "jev-latest"
|
|
339
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
|
|
340
381
|
presets: ["effect"], // optional, built-in rule sets
|
|
341
382
|
exclude: ["**/generated/**"], // optional, files no rule judges
|
|
342
383
|
overrides: { "effect/basics/instrument-with-pipe": "off" }, // optional, see Presets
|
|
@@ -471,6 +512,33 @@ export default defineConfig({ presets: ["effect"], rules: "./docs/adhere" });
|
|
|
471
512
|
|
|
472
513
|
Rules read through `rules` apply project-wide.
|
|
473
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
|
+
|
|
474
542
|
### Rule writing tips
|
|
475
543
|
|
|
476
544
|
We've evaluated different ways of giving Jev a rule, to catch the most
|
|
@@ -495,13 +563,30 @@ A preset has the shape of a config without `presets`: `rules`, and optionally
|
|
|
495
563
|
command line with `--preset`, repeated or separated by commas, as in
|
|
496
564
|
`--preset effect,alchemy`, in which case the config file is optional. Presets
|
|
497
565
|
named in both places apply together. Reports name a preset's rule with the
|
|
498
|
-
preset first, as in `alchemy/secrets/…`. There are
|
|
499
|
-
|
|
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.
|
|
500
585
|
- `effect`: Markdown rules in [`presets/effect/`](./presets/effect/), lifted
|
|
501
586
|
from the [effect-solutions](https://github.com/kitlangton/effect-solutions)
|
|
502
587
|
docs and the
|
|
503
588
|
[effect/platform](https://effect.website/docs/platform/introduction/) docs.
|
|
504
|
-
- `alchemy`:
|
|
589
|
+
- `alchemy`: 41 rules in [`presets/alchemy/`](./presets/alchemy/) for code that
|
|
505
590
|
deploys with [alchemy](https://alchemy.run), lifted from its docs and blog:
|
|
506
591
|
where Config and bindings are read, which resources keep their data, how
|
|
507
592
|
secrets stay out of bundles and logs, authorization on public URLs,
|
|
@@ -540,10 +625,9 @@ file does not show, so the rule also flags deletes of APIs that succeed
|
|
|
540
625
|
anyway; see [the study](eval/studies/idempotent-delete.md).
|
|
541
626
|
|
|
542
627
|
A preset rule says "must" only where its source makes a requirement, and
|
|
543
|
-
"should" where the source gives advice. In `effect`,
|
|
544
|
-
guidelines:
|
|
545
|
-
|
|
546
|
-
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
|
|
547
631
|
`Config.mapOrFail` as well as `Config.schema`, and the variants rule does not
|
|
548
632
|
rule out a `switch`. The rule that a command handler only parses input is
|
|
549
633
|
gone: the docs show that pattern but do not ask for it.
|
|
@@ -582,18 +666,56 @@ whose types have no requirements to find. The preset's
|
|
|
582
666
|
`services/dependencies-through-layers` asks for that, and
|
|
583
667
|
`services/operations-have-no-requirements` for the style it goes with.
|
|
584
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
|
+
|
|
585
705
|
### Precedence
|
|
586
706
|
|
|
587
707
|
Highest first: `--threshold` on the command line, the config file, presets in
|
|
588
708
|
order (a later preset wins), then the defaults `jev-latest` and `0.8`. A rule
|
|
589
709
|
in `rules` replaces a preset rule with the same id. A rule's own `threshold`
|
|
590
710
|
beats all of the above for that rule, and an entry in `overrides` beats the
|
|
591
|
-
rule's own `threshold` and `level`.
|
|
711
|
+
rule's own `threshold` and `level`. `--sufficiency-threshold` beats the
|
|
712
|
+
config's `sufficiencyThreshold`, which beats the default, `0.6`.
|
|
592
713
|
|
|
593
714
|
## How a file is judged
|
|
594
715
|
|
|
595
|
-
1. One Jev request per file. The state is the file's
|
|
596
|
-
|
|
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
|
|
597
719
|
carries the rule's description and its code under its words, `must` and
|
|
598
720
|
`never` (or a guideline's `should` and `should_not`): whether the file
|
|
599
721
|
diverges from the pattern `must` shows, with `never` as an example of
|
|
@@ -606,14 +728,22 @@ rule's own `threshold` and `level`.
|
|
|
606
728
|
project rule, the rule's question has a second one beside it, the linter
|
|
607
729
|
check (see [Validate](#validate)), with the same fields.
|
|
608
730
|
2. A second request only when at least one rule's probability is above its
|
|
609
|
-
threshold
|
|
610
|
-
|
|
611
|
-
|
|
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).
|
|
612
742
|
3. Jev reads at most 32k tokens of state and one question together, and 64k
|
|
613
743
|
tokens in a request. adhere estimates tokens from the JSON it sends, at
|
|
614
744
|
about three bytes a token. When a file's questions would not fit in one
|
|
615
745
|
request, they are split across several. A file whose code and longest
|
|
616
|
-
question would not fit together, or that has more than
|
|
746
|
+
question would not fit together, or that has more than 255 sections, is
|
|
617
747
|
skipped and counted in the summary, and the rest of the run goes on. So is
|
|
618
748
|
a file Jev itself counts as over its context, which text denser than the
|
|
619
749
|
estimate, such as CJK, can cause.
|
|
@@ -736,7 +866,7 @@ and `bun run test` runs the tests.
|
|
|
736
866
|
### Native executable
|
|
737
867
|
|
|
738
868
|
`bun run build` compiles `dist/adhere`, a single binary with Bun and the
|
|
739
|
-
|
|
869
|
+
presets inside it, through Bun's
|
|
740
870
|
[`--compile`](https://bun.sh/docs/bundler/executables) with
|
|
741
871
|
`--asset ./presets`. It runs without Bun or `node_modules` on the target
|
|
742
872
|
machine and still loads the repo's `config.ts` and Markdown rules from disk.
|
|
@@ -744,6 +874,12 @@ machine and still loads the repo's `config.ts` and Markdown rules from disk.
|
|
|
744
874
|
into its package under `dist/npm/`
|
|
745
875
|
([`scripts/npm-packages.ts`](./scripts/npm-packages.ts)).
|
|
746
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
|
+
|
|
747
883
|
Each platform's executable is its own npm package,
|
|
748
884
|
`@drkmttr/adhere-<platform>-<arch>`, limited by `os` and `cpu`, and an
|
|
749
885
|
optional dependency of `@drkmttr/adhere`, so an install fetches only its own
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules in .adhere/, judged by Jev.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"start": "bun src/main.ts",
|
|
33
33
|
"test": "vp test",
|
|
34
34
|
"typecheck": "tsc --noEmit && bun run lint",
|
|
35
|
-
"build": "bun build --compile --minify --sourcemap src/main.ts --asset ./presets --outfile dist/adhere",
|
|
35
|
+
"build": "bun build --compile --minify --sourcemap --define ADHERE_BUILD=\"\\\"$(git describe --tags --dirty --always)\\\"\" src/main.ts --asset ./presets --outfile dist/adhere",
|
|
36
36
|
"build:npm": "bun scripts/npm-packages.ts build",
|
|
37
37
|
"release": "release-it"
|
|
38
38
|
},
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
|
-
"@drkmttr/adhere-darwin-arm64": "0.
|
|
72
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
73
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
74
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
75
|
-
"@drkmttr/adhere-win32-x64": "0.
|
|
71
|
+
"@drkmttr/adhere-darwin-arm64": "0.11.0",
|
|
72
|
+
"@drkmttr/adhere-darwin-x64": "0.11.0",
|
|
73
|
+
"@drkmttr/adhere-linux-arm64": "0.11.0",
|
|
74
|
+
"@drkmttr/adhere-linux-x64": "0.11.0",
|
|
75
|
+
"@drkmttr/adhere-win32-x64": "0.11.0"
|
|
76
76
|
}
|
|
77
77
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Sequential effectful steps must be written with Effect.gen and yield*, never as nested flatMap or callback chains.
|
|
2
|
+
description: Sequential effectful steps must be written with Effect.gen and yield*, never as nested flatMap or callback chains. A single flatMap or andThen in a flat pipe is out of scope; the rule is about a step whose callback holds further steps.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
## Must
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: A primitive with semantic meaning, such as an id, email, URL, port, or count, must be a branded schema, never a bare string or number.
|
|
2
|
+
description: A primitive with semantic meaning, such as an id, email, URL, port, or count, must be declared as a branded schema, never as a bare string or number. The rule judges where a type is declared, such as a schema field, an interface, or a type alias; a literal value, local variable, or parameter whose type is declared elsewhere is out of scope.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
## Must
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Structured variants, alternatives that carry fields, must be Schema.TaggedClass members of a Schema.Union, never hand-written object types joined by a tag field. Simple alternatives without fields may be Schema.Literals.
|
|
2
|
+
description: Structured variants, alternatives that carry fields, must be Schema.TaggedClass members of a Schema.Union, never hand-written object types joined by a tag field. Simple alternatives without fields may be Schema.Literals. The rule judges declared union types and schemas; an object literal with a tag field is out of scope, and so is a type that mirrors a third-party format whose tag key that format fixes, such as the type of a Slack Block Kit block.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
## Must
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: A service that depends on other services must be a Context.Service built by a Layer that yields those services as it is constructed, never a factory function or class that takes services, clients, or their implementations as arguments.
|
|
2
|
+
description: A service that depends on other services must be a Context.Service built by a Layer that yields those services as it is constructed, never a factory function or class that takes services, clients, or their implementations as arguments and returns the service's operations with them built in. A helper that takes a client and one call's arguments and returns a single Effect is not a factory and is out of scope; the code that assembles the service from such helpers is in scope.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
## Must
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: An effect that subscribes to something, adds an event listener, starts a timer or an interval, or opens a connection must return a cleanup function that undoes it, never leave it running after the component unmounts or the effect runs again.
|
|
3
|
+
appliesTo: ["a React effect"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const onResize = () => setWidth(window.innerWidth);
|
|
11
|
+
window.addEventListener("resize", onResize);
|
|
12
|
+
return () => window.removeEventListener("resize", onResize);
|
|
13
|
+
}, []);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Never
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const onResize = () => setWidth(window.innerWidth);
|
|
21
|
+
window.addEventListener("resize", onResize);
|
|
22
|
+
}, []);
|
|
23
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Code that runs because the user did something, such as submitting a form, clicking a button, or choosing an option, must run in that event's handler, never in an effect that watches state the handler set. An effect is for keeping a component in sync with something outside React while it is shown.
|
|
3
|
+
appliesTo: ["a React component or hook"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
function CheckoutForm({ cart }: { cart: Cart }) {
|
|
10
|
+
const handleSubmit = async (event: FormEvent) => {
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
await placeOrder(cart);
|
|
13
|
+
showToast("Order placed");
|
|
14
|
+
};
|
|
15
|
+
return <form onSubmit={handleSubmit}>{/* fields */}</form>;
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Never
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
function CheckoutForm({ cart }: { cart: Cart }) {
|
|
23
|
+
const [submitted, setSubmitted] = useState(false);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (submitted) placeOrder(cart).then(() => showToast("Order placed"));
|
|
26
|
+
}, [submitted, cart]);
|
|
27
|
+
return <form onSubmit={() => setSubmitted(true)}>{/* fields */}</form>;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A component or hook that reads a value from a store outside React, such as a browser API like `navigator.onLine` or a store module, should subscribe to it with `useSyncExternalStore`, and should not copy the value into state from a subscription an effect sets up.
|
|
3
|
+
appliesTo: ["a React component or hook"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Should
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
const subscribe = (callback: () => void) => {
|
|
10
|
+
window.addEventListener("online", callback);
|
|
11
|
+
window.addEventListener("offline", callback);
|
|
12
|
+
return () => {
|
|
13
|
+
window.removeEventListener("online", callback);
|
|
14
|
+
window.removeEventListener("offline", callback);
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const useOnlineStatus = () =>
|
|
19
|
+
useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Should not
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
export function useOnlineStatus() {
|
|
26
|
+
const [isOnline, setIsOnline] = useState(true);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const update = () => setIsOnline(navigator.onLine);
|
|
29
|
+
window.addEventListener("online", update);
|
|
30
|
+
window.addEventListener("offline", update);
|
|
31
|
+
return () => {
|
|
32
|
+
window.removeEventListener("online", update);
|
|
33
|
+
window.removeEventListener("offline", update);
|
|
34
|
+
};
|
|
35
|
+
}, []);
|
|
36
|
+
return isOnline;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: An effect that requests data over the network must ignore or abort a response that arrives after its inputs changed or the component unmounted, such as with an `ignore` flag or an AbortController that its cleanup sets, never set state from every response in whatever order they arrive.
|
|
3
|
+
appliesTo: ["a React effect"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
let ignore = false;
|
|
11
|
+
fetchResults(query).then((results) => {
|
|
12
|
+
if (!ignore) setResults(results);
|
|
13
|
+
});
|
|
14
|
+
return () => {
|
|
15
|
+
ignore = true;
|
|
16
|
+
};
|
|
17
|
+
}, [query]);
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Never
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
fetchResults(query).then((results) => setResults(results));
|
|
25
|
+
}, [query]);
|
|
26
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A component must tell its parent about a change, through a callback prop such as `onChange`, in the event handler that makes the change, never from an effect that watches the component's own state.
|
|
3
|
+
appliesTo: ["a React component or hook"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
function Toggle({ onChange }: { onChange: (isOn: boolean) => void }) {
|
|
10
|
+
const [isOn, setIsOn] = useState(false);
|
|
11
|
+
const toggle = () => {
|
|
12
|
+
setIsOn(!isOn);
|
|
13
|
+
onChange(!isOn);
|
|
14
|
+
};
|
|
15
|
+
return <button onClick={toggle}>{isOn ? "On" : "Off"}</button>;
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Never
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
function Toggle({ onChange }: { onChange: (isOn: boolean) => void }) {
|
|
23
|
+
const [isOn, setIsOn] = useState(false);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
onChange(isOn);
|
|
26
|
+
}, [isOn, onChange]);
|
|
27
|
+
return <button onClick={() => setIsOn(!isOn)}>{isOn ? "On" : "Off"}</button>;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A Server Component must pass a Client Component only the fields it shows, never a whole database record, a secret, a token, or other data the user must not see, since every prop passed to a Client Component is sent to the browser.
|
|
3
|
+
appliesTo: ["a React Server Component"]
|
|
4
|
+
level: warning
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Must
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
export default async function ProfilePage({ params }: { params: { id: string } }) {
|
|
11
|
+
const user = await db.user.findUniqueOrThrow({ where: { id: params.id } });
|
|
12
|
+
return <ProfileCard name={user.name} avatarUrl={user.avatarUrl} />;
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Never
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
export default async function ProfilePage({ params }: { params: { id: string } }) {
|
|
20
|
+
const user = await db.user.findUniqueOrThrow({ where: { id: params.id } });
|
|
21
|
+
return <ProfileCard user={user} />;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A Server Function, one marked `"use server"`, is an endpoint any client can call with any arguments, so it must check that the caller is signed in and allowed to do what it does, and validate its arguments at runtime; it must never trust that only the app's own UI calls it.
|
|
3
|
+
appliesTo: ["a Server Function, marked \"use server\""]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
"use server";
|
|
10
|
+
|
|
11
|
+
export async function deletePost(input: unknown) {
|
|
12
|
+
const session = await getSession();
|
|
13
|
+
if (!session) throw new Error("Not signed in");
|
|
14
|
+
const { postId } = DeletePostInput.parse(input);
|
|
15
|
+
const post = await db.post.findUnique({ where: { id: postId } });
|
|
16
|
+
if (post?.authorId !== session.userId) throw new Error("Not allowed");
|
|
17
|
+
await db.post.delete({ where: { id: postId } });
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Never
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
"use server";
|
|
25
|
+
|
|
26
|
+
export async function deletePost(postId: string) {
|
|
27
|
+
await db.post.delete({ where: { id: postId } });
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: State that describes one thing's status, such as whether a request is being sent, was sent, or failed, should be a single state that holds one status at a time, and should not be separate booleans that can say contradictory things at once.
|
|
3
|
+
appliesTo: ["a React component or hook"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Should
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
const [status, setStatus] = useState<"typing" | "sending" | "sent">("typing");
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Should not
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
const [isSending, setIsSending] = useState(false);
|
|
16
|
+
const [isSent, setIsSent] = useState(false);
|
|
17
|
+
```
|