@drkmttr/adhere 0.10.0 → 0.12.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 (53) hide show
  1. package/README.md +226 -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 +85 -4
  35. package/src/config.ts +29 -1
  36. package/src/excerpt.ts +72 -62
  37. package/src/highlight.ts +2 -0
  38. package/src/init.ts +168 -13
  39. package/src/install.ts +147 -0
  40. package/src/markdown.ts +6 -0
  41. package/src/presets.ts +6 -0
  42. package/src/rules.ts +1 -1
  43. package/src/services/AuditCache.ts +31 -5
  44. package/src/services/Jev.http.ts +76 -11
  45. package/src/services/Jev.ts +191 -73
  46. package/src/services/SourceWalker.ts +23 -7
  47. package/src/suppress.ts +13 -7
  48. package/src/version.ts +25 -1
  49. package/src/workflows/audit.ts +96 -26
  50. package/src/workflows/format.ts +51 -11
  51. package/presets/alchemy/data/parameterized-sql.md +0 -17
  52. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
  53. 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
@@ -101,6 +139,9 @@ adhere lint --deny-warnings # fail on warnings as well as errors
101
139
  adhere lint --log-level debug # log each request to Jev on stderr
102
140
  adhere validate # check the rules' wording; ask Jev whether any contradict
103
141
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
142
+ adhere init --shared org/repo # scaffold a repo of rules other repos install
143
+ adhere list org/repo # list the rules in another repo's .adhere/
144
+ adhere install org/repo # copy them into this repo's .adhere/org/repo/
104
145
  adhere login # save a TypeSafe AI API key for later runs
105
146
  adhere logout # delete the saved key
106
147
  adhere skill # print the agent skill (below)
@@ -110,10 +151,6 @@ Bare `adhere` prints the help, which lists the commands, and
110
151
  `adhere <command> --help` lists a command's flags. `adhere --completions <shell>`
111
152
  prints a completion script.
112
153
 
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
154
  ### Before and during a run
118
155
 
119
156
  Until it knows what judging takes, a status line on stderr says what `lint` is
@@ -200,7 +237,8 @@ delete: Effect.fn(function* ({ output }) {
200
237
 
201
238
  On a line of its own, `adhere-ignore` covers the statement that starts on the
202
239
  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
240
+ lines it points at can move between runs. A finding without lines is covered
241
+ when its section overlaps the statement. At the end of a line of code, it
204
242
  covers the statement that starts on that line. `adhere-ignore-file`, anywhere
205
243
  in a file, covers the whole file, and the rules it names are not judged there
206
244
  at all. A comment names rules as a report does, a preset's with its preset
@@ -226,17 +264,22 @@ adhere lint --log-level debug 2> adhere.log
226
264
 
227
265
  ### What gets read
228
266
 
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
267
+ `adhere lint` reads the TypeScript files under the working directory, `.ts`,
268
+ `.tsx`, `.mts`, and `.cts`, except declaration files such as `.d.ts` and
269
+ config files. When the working directory contains `agents/`, `apps/`, or
231
270
  `packages/`, only those trees are read. Below the working directory, anything
232
271
  under `node_modules/`, `dist/`, `coverage/`, `vendor/`, `e2e/`, `references/`,
233
272
  `.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
273
+ skipped; the directories above it do not count. JavaScript files are not read,
274
+ and `.gitignore` is not consulted.
275
+
276
+ Tests are judged only by rules about tests. A test is a `.test` or `.spec`
277
+ file, such as `a.test.ts` or `Button.spec.tsx`; a fixture or test helper named
278
+ as one, such as `fixtures.ts`, `GitHubHttpFixtures.ts`,
279
+ `ledger-copy.fixture.ts`, `linear-test-helpers.ts`, or `test-utils.ts`; or any
280
+ file under a `test/`, `tests/`, `__tests__/`,
281
+ `testing/`, `fixtures/`, `fixture/`, `__fixtures__/`, or `__mocks__/`
282
+ directory, and only a rule whose front matter says `tests` judges
240
283
  one (see [Rules as Markdown files](#rules-as-markdown-files)). On alchemy, 38%
241
284
  of the findings from rules not about tests were in test helpers and fixtures.
242
285
 
@@ -267,6 +310,52 @@ msgpackr-extract, an optional native add-on that comes with `effect` and that
267
310
  adhere never uses, with a warning instead of failing.
268
311
  It skips the install when `package.json` already lists adhere.
269
312
 
313
+ `adhere init --shared org/repo` scaffolds a repo whose rules other repos copy
314
+ in with `adhere install` (see [Sharing rules](#sharing-rules)) instead of one
315
+ that lints itself. It writes the two example rules and no config, since
316
+ install copies only rule files, and with them:
317
+
318
+ - `README.md`, which says what the repo is for, how to install its rules, and
319
+ how to add one.
320
+ - `.github/workflows/adhere.yaml`, which runs `adhere validate` on every push
321
+ to main and every pull request, with the `TYPESAFE_API_KEY` secret. A pull
322
+ request from a fork gets no secrets, so validate refuses there.
323
+ - `alchemy.run.ts`, an [alchemy](https://alchemy.run) stack that creates the
324
+ private GitHub repo, or adopts it, and sets that secret from
325
+ `TYPESAFE_API_KEY` when deployed with `npx alchemy deploy`.
326
+ - `package.json`, with alchemy and effect pinned to versions that work
327
+ together, which init installs, and a `.gitignore` for them and alchemy's
328
+ state.
329
+
330
+ ### Sharing rules
331
+
332
+ An organization's rules can live in one repo's `.adhere/`, and other repos
333
+ copy them in. `adhere list org/repo` prints each rule there with its
334
+ description. `adhere install org/repo` copies every rule into this repo's
335
+ `.adhere/org/repo/`, so `data/brand-ports` from darkmatter/standards is
336
+ `.adhere/darkmatter/standards/data/brand-ports.md`, the rule
337
+ `darkmatter/standards/data/brand-ports`, and two repos' rules never collide.
338
+ A topic or a rule after the repo copies only that part, and `#` picks a branch
339
+ or tag:
340
+
341
+ ```sh
342
+ adhere install darkmatter/standards/data # the data topic
343
+ adhere install darkmatter/standards/data/brand-ports # one rule
344
+ adhere install darkmatter/standards#v3 # every rule, as tagged v3
345
+ ```
346
+
347
+ The copies are the repo's own rules from then on: commit them, edit them, or
348
+ start new rules from them. Nothing tracks where they came from, so a later
349
+ install skips a file already there, as init does, and `--force` overwrites it,
350
+ edits and all. Only Markdown rule files are copied; the source's config, its
351
+ inline rules, and its cache are not. A config with inline `rules` replaces
352
+ the `.adhere/` rule files, so it replaces copied ones too.
353
+
354
+ The source is cloned with `git` from `https://github.com/org/repo.git`, so a
355
+ private repo needs git's credentials for GitHub, as `gh auth setup-git` sets
356
+ up. To clone over SSH instead, let git rewrite the URL:
357
+ `git config --global url.git@github.com:.insteadOf https://github.com/`.
358
+
270
359
  ### Validate
271
360
 
272
361
  `adhere validate` loads the config, rule files, and presets the way `lint`
@@ -337,6 +426,7 @@ import { defineConfig } from "@drkmttr/adhere";
337
426
  export default defineConfig({
338
427
  model: "jev-latest", // optional, default "jev-latest"
339
428
  threshold: 0.8, // optional, default 0.8
429
+ sufficiencyThreshold: 0.6, // optional, default 0.6: below it, a finding warns to check outside the file
340
430
  presets: ["effect"], // optional, built-in rule sets
341
431
  exclude: ["**/generated/**"], // optional, files no rule judges
342
432
  overrides: { "effect/basics/instrument-with-pipe": "off" }, // optional, see Presets
@@ -471,6 +561,33 @@ export default defineConfig({ presets: ["effect"], rules: "./docs/adhere" });
471
561
 
472
562
  Rules read through `rules` apply project-wide.
473
563
 
564
+ ### Scoping a rule
565
+
566
+ A rule can say where it applies, apart from what it asks for, with
567
+ `appliesTo` and `excludeIf`: each a list of descriptions of code, written in
568
+ front matter as a JSON array on one line, or as an array inline in a config.
569
+
570
+ ```md
571
+ ---
572
+ description: Structured variants must be Schema.TaggedClass members of a Schema.Union, never hand-written object types joined by a tag field.
573
+ appliesTo: ["a declared union type or schema"]
574
+ excludeIf: ["a type that mirrors a third-party format whose tag key that format fixes, such as a Slack Block Kit block"]
575
+ ---
576
+ ```
577
+
578
+ Each goes to Jev as a question of its own, in the same request as the rule,
579
+ about the code that breaks the rule rather than the whole file: for
580
+ `appliesTo`, whether any of that code is as described, and for `excludeIf`,
581
+ whether all of it is. A finding stands only when Jev says yes, above 0.5, to
582
+ every `appliesTo` and to no `excludeIf`, so a file with a real violation
583
+ beside code an `excludeIf` describes keeps its finding. The scores are cached
584
+ with the rule's judgment, and editing a matcher judges the rule again, as
585
+ editing its description does. At `--log-level debug`, lint logs each finding
586
+ its matchers dropped, with their scores.
587
+
588
+ Keeping the scope out of the description keeps the description to what the
589
+ rule asks for, which the report shows, and gives each part a score of its own.
590
+
474
591
  ### Rule writing tips
475
592
 
476
593
  We've evaluated different ways of giving Jev a rule, to catch the most
@@ -495,13 +612,30 @@ A preset has the shape of a config without `presets`: `rules`, and optionally
495
612
  command line with `--preset`, repeated or separated by commas, as in
496
613
  `--preset effect,alchemy`, in which case the config file is optional. Presets
497
614
  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
-
615
+ preset first, as in `alchemy/secrets/…`. There are five:
616
+
617
+ - `typescript`: 11 rules in [`presets/typescript/`](./presets/typescript/) for
618
+ any TypeScript project, drawn from the TypeScript Handbook and the Google
619
+ TypeScript Style Guide: data from outside checked at runtime, invalid states
620
+ unrepresentable, errors never swallowed, resources released on every path,
621
+ arguments not mutated, and tests that assert outcomes and stand alone.
622
+ - `react`: 10 rules in [`presets/react/`](./presets/react/) for components
623
+ and hooks, lifted from react.dev, mostly
624
+ [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
625
+ and [Choosing the State Structure](https://react.dev/learn/choosing-the-state-structure):
626
+ logic for an event in its handler rather than an effect, effects that clean
627
+ up and ignore stale fetches, `useSyncExternalStore` for outside stores, state
628
+ that neither copies props nor contradicts itself, and Server Functions and
629
+ Server Components that guard what crosses to the client.
630
+ - `security`: 4 rules in [`presets/security/`](./presets/security/), drawn
631
+ from OWASP's cheat sheets: secrets kept out of logs, error messages, and
632
+ responses; parameterized SQL; no untrusted input in shell commands, `eval`,
633
+ or file paths; and secrets compared in constant time. It has no topics.
500
634
  - `effect`: Markdown rules in [`presets/effect/`](./presets/effect/), lifted
501
635
  from the [effect-solutions](https://github.com/kitlangton/effect-solutions)
502
636
  docs and the
503
637
  [effect/platform](https://effect.website/docs/platform/introduction/) docs.
504
- - `alchemy`: 43 rules in [`presets/alchemy/`](./presets/alchemy/) for code that
638
+ - `alchemy`: 41 rules in [`presets/alchemy/`](./presets/alchemy/) for code that
505
639
  deploys with [alchemy](https://alchemy.run), lifted from its docs and blog:
506
640
  where Config and bindings are read, which resources keep their data, how
507
641
  secrets stay out of bundles and logs, authorization on public URLs,
@@ -540,10 +674,9 @@ file does not show, so the rule also flags deletes of APIs that succeed
540
674
  anyway; see [the study](eval/studies/idempotent-delete.md).
541
675
 
542
676
  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
677
+ "should" where the source gives advice. In `effect`, two rules are
678
+ guidelines: test layers are in memory, outside integration tests, and tests
679
+ provide config through a layer. Config validation accepts
547
680
  `Config.mapOrFail` as well as `Config.schema`, and the variants rule does not
548
681
  rule out a `switch`. The rule that a command handler only parses input is
549
682
  gone: the docs show that pattern but do not ask for it.
@@ -582,18 +715,56 @@ whose types have no requirements to find. The preset's
582
715
  `services/dependencies-through-layers` asks for that, and
583
716
  `services/operations-have-no-requirements` for the style it goes with.
584
717
 
718
+ `typescript` leaves out what typescript-eslint and ESLint check exactly:
719
+ unhandled promises (`no-floating-promises`), throwing non-errors
720
+ (`only-throw-error`), `any` (`no-explicit-any`), exhaustive switches
721
+ (`switch-exhaustiveness-check`), a lost `cause` (`preserve-caught-error`), and
722
+ empty catch blocks (`no-empty`). Four of its rules report warnings. Three are
723
+ advice that code often has reason to set aside: assertions that claim only
724
+ what the code established, types derived rather than restated, and
725
+ independent awaits run concurrently. The fourth, that HTTP requests carry a
726
+ timeout, was right on fewer than half its findings above 0.8 on the eval:
727
+ many of the rest were calls through a client with a default timeout that the
728
+ file does not show.
729
+ Two rules the eval found mostly wrong are gone: that tests use fake time
730
+ rather than real waits, and that importing a module does no work.
731
+
732
+ Where effect or alchemy had a rule that `typescript` or `security` has, that
733
+ one is kept and the other is gone: typescript's
734
+ `async/network-calls-have-timeouts` replaced effect's
735
+ `basics/external-calls-are-resilient`, and `security/parameterized-queries`
736
+ and `security/no-secrets-in-output` replaced alchemy's
737
+ `data/parameterized-sql` and `secrets/never-logged-returned-or-output`. A
738
+ project on Effect or alchemy names those presets too, for those rules.
739
+
740
+ `react` leaves out what the React Compiler's lint rules check, in
741
+ [eslint-plugin-react-hooks](https://react.dev/reference/eslint-plugin-react-hooks)'s
742
+ recommended set and in Oxlint: pure render (`purity`), props and state never
743
+ mutated (`immutability`), refs not read during render (`refs`), no `setState`
744
+ in render or synchronously in an effect (`set-state-in-render`,
745
+ `set-state-in-effect`), which covers state derived in an effect, and no
746
+ component defined inside another (`static-components`). Its rules judge `.ts`
747
+ files as well as `.tsx`, and apply only to components, hooks, effects, and
748
+ Server Functions, so other files cost their checks and find nothing.
749
+ `server/no-private-data-to-client` reports warnings: a file does not show
750
+ whether the component it passes a record to is a Client Component. So does
751
+ `state/store-ids-not-copies`, which also flags state that copies a string or
752
+ an option from a fixed list, where a copy cannot go stale.
753
+
585
754
  ### Precedence
586
755
 
587
756
  Highest first: `--threshold` on the command line, the config file, presets in
588
757
  order (a later preset wins), then the defaults `jev-latest` and `0.8`. A rule
589
758
  in `rules` replaces a preset rule with the same id. A rule's own `threshold`
590
759
  beats all of the above for that rule, and an entry in `overrides` beats the
591
- rule's own `threshold` and `level`.
760
+ rule's own `threshold` and `level`. `--sufficiency-threshold` beats the
761
+ config's `sufficiencyThreshold`, which beats the default, `0.6`.
592
762
 
593
763
  ## How a file is judged
594
764
 
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
765
+ 1. One Jev request per file. The state is the file's sections (see the
766
+ report, above) under their numbers from 1, as `{ "1": "…", "2": "…" }`,
767
+ without their comments (see [Comments](#comments)), and nothing else. Each rule is one `noul` (yes/no probability) question that
597
768
  carries the rule's description and its code under its words, `must` and
598
769
  `never` (or a guideline's `should` and `should_not`): whether the file
599
770
  diverges from the pattern `must` shows, with `never` as an example of
@@ -606,14 +777,22 @@ rule's own `threshold` and `level`.
606
777
  project rule, the rule's question has a second one beside it, the linter
607
778
  check (see [Validate](#validate)), with the same fields.
608
779
  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).
780
+ threshold. Per flagged rule, when the file has more than one section, one
781
+ `choice` question among the sections' numbers, which yields the section to
782
+ report. The options carry no text of their own, since each is a key of the
783
+ state: describing each section by its first line, or by what it declares,
784
+ located no better on the eval. For each section, two `choice` questions
785
+ among its lines, each option the line's text, of the line the violation
786
+ starts on and the one it ends on; the chosen section's are underlined.
787
+ Beside them, one `noul` carrying the rule:
788
+ whether you can tell if `code` breaks it from `code` alone, without knowing
789
+ what other files, libraries, services, or configuration do. It yields the
790
+ warning (see the report, above).
612
791
  3. Jev reads at most 32k tokens of state and one question together, and 64k
613
792
  tokens in a request. adhere estimates tokens from the JSON it sends, at
614
793
  about three bytes a token. When a file's questions would not fit in one
615
794
  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
795
+ question would not fit together, or that has more than 255 sections, is
617
796
  skipped and counted in the summary, and the rest of the run goes on. So is
618
797
  a file Jev itself counts as over its context, which text denser than the
619
798
  estimate, such as CJK, can cause.
@@ -736,7 +915,7 @@ and `bun run test` runs the tests.
736
915
  ### Native executable
737
916
 
738
917
  `bun run build` compiles `dist/adhere`, a single binary with Bun and the
739
- `effect` preset inside it, through Bun's
918
+ presets inside it, through Bun's
740
919
  [`--compile`](https://bun.sh/docs/bundler/executables) with
741
920
  `--asset ./presets`. It runs without Bun or `node_modules` on the target
742
921
  machine and still loads the repo's `config.ts` and Markdown rules from disk.
@@ -744,6 +923,12 @@ machine and still loads the repo's `config.ts` and Markdown rules from disk.
744
923
  into its package under `dist/npm/`
745
924
  ([`scripts/npm-packages.ts`](./scripts/npm-packages.ts)).
746
925
 
926
+ `adhere --version` tells the builds apart. A release prints its version, as
927
+ `adhere v0.10.0`. `bun run build` prints `git describe --tags --dirty` where
928
+ it was built, as `adhere v0.10.0-16-g9a41328`: 16 commits after v0.10.0, at
929
+ `9a41328`, ending in `-dirty` when the tree had changes. Run from source, it
930
+ prints the checkout's version marked `(source)`.
931
+
747
932
  Each platform's executable is its own npm package,
748
933
  `@drkmttr/adhere-<platform>-<arch>`, limited by `os` and `cpu`, and an
749
934
  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.12.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.12.0",
72
+ "@drkmttr/adhere-darwin-x64": "0.12.0",
73
+ "@drkmttr/adhere-linux-arm64": "0.12.0",
74
+ "@drkmttr/adhere-linux-x64": "0.12.0",
75
+ "@drkmttr/adhere-win32-x64": "0.12.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
+ ```