@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.
Files changed (51) hide show
  1. package/README.md +177 -41
  2. package/package.json +7 -7
  3. package/presets/effect/basics/gen-for-sequencing.md +1 -1
  4. package/presets/effect/data/brand-meaningful-primitives.md +1 -1
  5. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  6. package/presets/effect/services/dependencies-through-layers.md +1 -1
  7. package/presets/react/effects/clean-up-what-they-start.md +23 -0
  8. package/presets/react/effects/event-logic-in-handlers.md +29 -0
  9. package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
  10. package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
  11. package/presets/react/effects/notify-parents-in-handlers.md +29 -0
  12. package/presets/react/server/no-private-data-to-client.md +23 -0
  13. package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
  14. package/presets/react/state/no-contradictory-state.md +17 -0
  15. package/presets/react/state/no-props-copied-into-state.md +23 -0
  16. package/presets/react/state/store-ids-not-copies.md +20 -0
  17. package/presets/security/constant-time-secret-compare.md +22 -0
  18. package/presets/security/no-secrets-in-output.md +19 -0
  19. package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
  20. package/presets/security/parameterized-queries.md +16 -0
  21. package/presets/typescript/async/concurrent-independent-work.md +23 -0
  22. package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
  23. package/presets/typescript/design/no-argument-mutation.md +20 -0
  24. package/presets/typescript/errors/no-swallowed-errors.md +29 -0
  25. package/presets/typescript/errors/release-on-every-path.md +29 -0
  26. package/presets/typescript/testing/assert-outcomes.md +22 -0
  27. package/presets/typescript/testing/independent-tests.md +41 -0
  28. package/presets/typescript/types/assertions-only-when-proven.md +20 -0
  29. package/presets/typescript/types/derive-dont-restate.md +24 -0
  30. package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
  31. package/presets/typescript/types/validate-external-data.md +21 -0
  32. package/skills/adhere/SKILL.md +4 -3
  33. package/skills/adhere-fix/SKILL.md +10 -4
  34. package/src/cli.ts +9 -1
  35. package/src/config.ts +29 -1
  36. package/src/excerpt.ts +72 -62
  37. package/src/highlight.ts +2 -0
  38. package/src/markdown.ts +6 -0
  39. package/src/presets.ts +6 -0
  40. package/src/rules.ts +1 -1
  41. package/src/services/AuditCache.ts +31 -5
  42. package/src/services/Jev.http.ts +76 -11
  43. package/src/services/Jev.ts +191 -73
  44. package/src/services/SourceWalker.ts +23 -7
  45. package/src/suppress.ts +13 -7
  46. package/src/version.ts +25 -1
  47. package/src/workflows/audit.ts +96 -26
  48. package/src/workflows/format.ts +51 -11
  49. package/presets/alchemy/data/parameterized-sql.md +0 -17
  50. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
  51. 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. A rule is a one-sentence
6
- description in RFC 2119's words, like "business logic must live in services",
7
- with code that must be written that way, code that must never be, or both. For
8
- 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
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 line Jev points at. 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
- 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:
20
28
 
21
29
  ```sh
22
- TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset effect
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/external-calls-are-resilient`, so a report that mixes presets
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 line Jev points at, underlined, with the code around it: the
79
- largest statement around the line that is 30 lines or fewer, usually the whole
80
- function, and up to 3 lines of whole statements on either side. The
81
- hint is the rule's code that must be written; a rule with only code that must
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
- line it points at can move between runs. At the end of a line of code, it
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 `.ts` files under the working directory, except `.d.ts`
230
- and config files. When the working directory contains `agents/`, `apps/`, or
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. `.tsx` files are not read, and
235
- `.gitignore` is not consulted.
236
-
237
- Tests are judged only by rules about tests. A test is a `.test.ts` or
238
- `.spec.ts` file, or any file under a `test/`, `tests/`, `__tests__/`, or
239
- `fixtures/` directory, and only a rule whose front matter says `tests` judges
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 two:
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`: 43 rules in [`presets/alchemy/`](./presets/alchemy/) for code that
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`, three rules are
544
- guidelines: network calls carry a timeout and a retry schedule, unless their
545
- client already applies both; test layers are in memory, outside integration
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 numbered lines, without
596
- their comments (see [Comments](#comments)), and 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
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: one `choice` question per flagged rule over the file's non-blank
610
- lines, which yields the line to report. A file with more than 255 non-blank
611
- 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).
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 5100 lines, is
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
- `effect` preset inside it, through Bun's
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.10.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.10.0",
72
- "@drkmttr/adhere-darwin-x64": "0.10.0",
73
- "@drkmttr/adhere-linux-arm64": "0.10.0",
74
- "@drkmttr/adhere-linux-x64": "0.10.0",
75
- "@drkmttr/adhere-win32-x64": "0.10.0"
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
+ ```