@drkmttr/adhere 0.7.3 → 0.8.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 CHANGED
@@ -8,9 +8,10 @@ model) whether the file breaks each rule, gets a calibrated probability per
8
8
  rule, and reports the ones above a threshold with the line Jev points at. The
9
9
  report uses the same frame as `vp lint`.
10
10
 
11
- Deterministic rules (substring matches, type checks) belong in a normal linter.
11
+ Deterministic rules (substring matches, type checks) belong in a normal linter;
12
+ `adhere validate` points out a rule that looks like one.
12
13
 
13
- A preset for effect is included which can be run without setup:
14
+ A preset for effect is included which can be run without setup:
14
15
 
15
16
  ```sh
16
17
  TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset effect
@@ -36,20 +37,25 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
36
37
 
37
38
  ```sh
38
39
  adhere init # .adhere/config.ts and two example rules
39
- echo ".adhere/cache/" >> .gitignore
40
40
  adhere login # save your TypeSafe AI API key, once
41
- adhere validate # Checks your rules for contradictions using Jev
41
+ adhere validate # Checks your rules' wording, and contradictions using Jev
42
42
  adhere lint # audit the working directory
43
+ git add .adhere # commit the rules, and the judgments they cost
43
44
  ```
44
45
 
45
46
  Without a config or rules, `adhere lint --preset effect` audits against the
46
47
  built-in Effect rules. A finding looks like this:
47
48
 
48
49
  ```text
49
- × data/brand-ports (0.93): A port is a branded, range-checked integer, not a bare number.
50
- ╭─[src/server.ts:4:1]
51
- 4 │ const port: number = Number(process.env.PORT ?? 3000);
52
- · ──────────────────────────────────────────────────────
50
+ × data/brand-ports (0.93): A port must be a branded, range-checked integer, never a bare number.
51
+ ╭─[src/server.ts:6:3]
52
+ 4 │ export const serve = Effect.gen(function* () {
53
+ 5 │ const host = process.env.HOST ?? "localhost";
54
+ 6 │ const port: number = Number(process.env.PORT ?? 3000);
55
+ · ──────────────────────────────────────────────────────
56
+ 7 │ yield* listen({ host, port });
57
+ 8 │ yield* Effect.log(`Listening on ${host}:${port}`);
58
+ 9 │ });
53
59
  ╰────
54
60
  hint: const Port = Schema.Int.pipe(
55
61
  Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
@@ -60,7 +66,10 @@ Found 1 error.
60
66
  42 files, 3 judged, 39 cached.
61
67
  ```
62
68
 
63
- The header is the rule id, Jev's probability, and the rule's description. The
69
+ The header is the rule id, Jev's probability, and the rule's description.
70
+ Under it is the line Jev points at, underlined, with the code around it: the
71
+ largest statement around the line that is 30 lines or fewer, usually the whole
72
+ function, and up to 3 lines of whole statements on either side. The
64
73
  hint is the rule's code that must be written; a rule with only code that must
65
74
  never be written shows that code, labeled `never:`, instead. On a terminal, the report is in color and the code
66
75
  in it is highlighted. The exit code is 0 when nothing is reported and 1 when
@@ -78,7 +87,8 @@ adhere lint --yes # send the requests without asking first
78
87
  adhere lint --limit 500 # judge at most 500 checks; the rest wait for the next run
79
88
  adhere lint --rpm 30 # send at most 30 requests a minute
80
89
  adhere lint --filter 'src/**' # read only the files a glob matches
81
- adhere validate # load the config and rules, ask Jev whether any contradict
90
+ adhere lint --log-level debug # log each request to Jev on stderr
91
+ adhere validate # check the rules' wording; ask Jev whether any contradict
82
92
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
83
93
  adhere login # save a TypeSafe AI API key for later runs
84
94
  adhere logout # delete the saved key
@@ -129,6 +139,22 @@ more, and start a pattern with `!` to leave out what it matches. Together:
129
139
  adhere lint --filter 'packages/api/**' --filter '!**/generated/**' --limit 200 --rpm 30
130
140
  ```
131
141
 
142
+ ### Logging a run
143
+
144
+ `--log-level debug` logs a run's work on stderr: the config it loaded, how many
145
+ paths it listed and how many of them it reads, and each request to Jev, with
146
+ the file it is for, about how many tokens it carries, the HTTP status, how long
147
+ it took, and Cloudflare's Ray ID. A failed attempt is logged even when a retry
148
+ hides it. `--log-level trace` adds each file as it is read, planned, and
149
+ answered from the cache, and the first 4000 characters of any error Jev's API
150
+ answers with. The counter stays off at these levels, since its redraws would
151
+ garble the lines, and stdout still carries only the report, so the log can go
152
+ to a file of its own:
153
+
154
+ ```sh
155
+ adhere lint --log-level debug 2> adhere.log
156
+ ```
157
+
132
158
  ### What gets read
133
159
 
134
160
  `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
@@ -150,16 +176,45 @@ kept, and no second one is added, even with `--force`.
150
176
 
151
177
  `adhere validate` loads the config, rule files, and presets the way `lint`
152
178
  does, so a file that fails to decode refuses here too, and it prints how many
153
- rules it loaded. `--preset` adds a built-in rule set, as for `lint`. It then
154
- asks Jev whether any two rules that apply to the same files contradict, so that
155
- no code can follow both: one request names, per rule, the rule it conflicts
156
- with, if any, and each named pair then gets a probability from a request of
157
- its own, holding only those two rules, since other rules beside them dilute
158
- the judgment. A pair above
159
- the threshold is printed with both rule files, and the exit code is 1. Two
160
- rules with the same id are not compared: a nested rule that shares an id
161
- shadows the other on purpose. When no two rules share files nothing is sent;
162
- otherwise `validate` needs the API key, as `lint` does.
179
+ rules it loaded. `--preset` adds a built-in rule set, as for `lint`.
180
+
181
+ Next it checks each rule's wording against the
182
+ [rule writing tips](#rule-writing-tips), which sends nothing, and prints each
183
+ rule worded otherwise with its file and how:
184
+
185
+ - its description does not say a word its code is under, such as "never"
186
+ beside a `never` block;
187
+ - it has code to write but no example of code that breaks it;
188
+ - its description says the other kind of rule's words: "should" in a
189
+ requirement, or "must" or "never" in a guideline.
190
+
191
+ A rule with only code that must never be written needs no `must` example, as
192
+ [Rules as Markdown files](#rules-as-markdown-files) explains. The tips are
193
+ advice, so the wording does not change the exit code.
194
+
195
+ Then it reports the linter check, which also sends nothing. While `lint`
196
+ judges a file against one of the project's own rules, it asks Jev a second
197
+ question beside it: should the rule have been checked by a regular linter, that
198
+ is, could a linter or type checker have decided exactly whether this file
199
+ follows it? It asks on 10 files per rule, spread over the files it judges, and
200
+ keeps the answers in the cache until the rule's text changes. `validate` prints
201
+ each rule flagged on 7 or more of its 10 files as one a regular linter should
202
+ probably check, and each flagged on 3 to 6 as a rule that reads differently
203
+ from file to file, whose description should say more precisely what it
204
+ applies to. A rule with fewer than 10 answers is counted as waiting; `lint`
205
+ collects them as it judges files, so a repo whose files are all cached collects
206
+ them as its files change. Preset rules are left out: they are not the
207
+ project's to change. The linter check is advice too.
208
+
209
+ It then asks Jev whether any two rules that apply to the same files
210
+ contradict, so that no code can follow both: one request names, per rule, the
211
+ rule it conflicts with, if any, and each named pair then gets a probability
212
+ from a request of its own, holding only those two rules, since other rules
213
+ beside them dilute the judgment. A pair above the threshold is printed with
214
+ both rule files, and the exit code is 1. Two rules with the same id are not
215
+ compared: a nested rule that shares an id shadows the other on purpose. When
216
+ no two rules share files nothing is sent; otherwise `validate` needs the API
217
+ key, as `lint` does.
163
218
 
164
219
  ### Login
165
220
 
@@ -309,18 +364,47 @@ violations with the fewest false positives. In general:
309
364
 
310
365
  On the Effect preset, rules written this way cut the findings Jev got wrong at
311
366
  the default threshold from 6 to 1, and caught as many violations. For more
312
- about the evaluations, see [the eval](eval/README.md).
367
+ about the evaluations, see [the eval](eval/README.md). `adhere validate` lists
368
+ each rule worded otherwise.
313
369
 
314
370
  ### Presets
315
371
 
316
372
  A preset has the shape of a config without `presets`: `rules`, and optionally
317
- `model` and `threshold`. `effect` is the one preset: 26 Markdown rules in
373
+ `model` and `threshold`. `effect` is the one preset: 19 Markdown rules in
318
374
  [`presets/effect/`](./presets/effect/), lifted from the
319
375
  [effect-solutions](https://github.com/kitlangton/effect-solutions) docs and the
320
376
  [effect/platform](https://effect.website/docs/platform/introduction/) docs.
321
377
  Name it in the config, or on the command line with `--preset effect`, in which
322
378
  case the config file is optional.
323
379
 
380
+ The preset leaves out conventions a linter checks exactly. Effect's language
381
+ service, [`@effect/tsgo`](https://github.com/Effect-TS/tsgo) on TypeScript 7,
382
+ checks these seven, which the preset checked through 0.7. Most are off by
383
+ default; `npx @effect/tsgo setup` installs it, and these lines in its plugin
384
+ options in `tsconfig.json` turn them on:
385
+
386
+ ```jsonc
387
+ "diagnosticSeverity": {
388
+ "strictEffectProvide": "warning", // layers are provided once, at the entry point
389
+ "leakingRequirements": "warning", // service methods have no requirements
390
+ "effectFnOpportunity": "warning", // a named function returning an Effect uses Effect.fn
391
+ "preferSchemaOverJson": "warning", // JSON is decoded with Schema, not JSON.parse
392
+ "nodeBuiltinImport": "warning", // platform services, not node: builtins
393
+ "globalFetch": "warning",
394
+ "globalFetchInEffect": "warning",
395
+ "processEnv": "warning",
396
+ "processEnvInEffect": "warning",
397
+ "globalRandom": "warning", // the Random service, not Math.random
398
+ "globalRandomInEffect": "warning",
399
+ "globalErrorInEffectFailure": "warning", // tagged errors, not Error
400
+ "extendsNativeError": "warning"
401
+ }
402
+ ```
403
+
404
+ `npx @effect/tsgo diagnostics --project tsconfig.json` runs them in CI. Bun
405
+ globals, which the old platform rule also ruled out, need a lint rule of their
406
+ own, such as `no-restricted-globals`.
407
+
324
408
  ### Precedence
325
409
 
326
410
  Highest first: `--threshold` on the command line, the config file, presets in
@@ -340,7 +424,9 @@ beats all of the above for that rule.
340
424
  at the rule's scope, so a file with no code the rule is about is a no. Since
341
425
  no rule sits in the shared state, a rule's probability depends only on the
342
426
  file and that rule, not on which other rules share the request. Every
343
- question shares the state cost of the request.
427
+ question shares the state cost of the request. On up to 10 files per
428
+ project rule, the rule's question has a second one beside it, the linter
429
+ check (see [Validate](#validate)), with the same fields.
344
430
  2. A second request only when at least one rule's probability is above its
345
431
  threshold: one `choice` question per flagged rule over the file's non-blank
346
432
  lines, which yields the line to report. A file with more than 255 non-blank
@@ -356,15 +442,42 @@ beats all of the above for that rule.
356
442
 
357
443
  ## Cache
358
444
 
359
- Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
360
- per file. An entry stores the file's content hash and, per rule, the
361
- probability, the located line, and a fingerprint of the model and the
362
- question asked for the rule, which carries the rule's text. A changed file
363
- re-judges every rule for that file. An edited rule re-judges only that rule,
364
- and an adhere that asks its questions differently re-judges every rule once.
365
- A lowered threshold locates cached judgments that are newly above it without
366
- judging again. Entries depend on content, not on the
367
- machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
445
+ Judgments are cached in `.adhere/cache/`. Commit it. Every judgment is a paid
446
+ request, and Jev's answers vary a little from run to run, so a committed cache
447
+ gives everyone and CI the same findings without paying for them again, and a
448
+ pull request changes the judgments of only the files it changes.
449
+
450
+ `files/` holds Jev's answers by the content they are about: each cache file is
451
+ named for the hash of a source file's content, so a moved or copied file keeps
452
+ its judgments, and identical files share them. Each answer sits under a
453
+ fingerprint of the model and the question asked for the rule, which carries
454
+ the rule's text. A changed file re-judges every rule for that file. An edited
455
+ rule re-judges only that rule, and an adhere that asks its questions
456
+ differently re-judges every rule once. A lowered threshold locates cached
457
+ judgments that are newly above it without judging again. `tallies/` keeps the
458
+ linter check's answers, one tally per rule, under a key that changes with the
459
+ rule's text and the model.
460
+
461
+ A cache file is written once and never changed: it is also named for the hash
462
+ of its own text. Two branches that judge the same code add files rather than
463
+ edit them, so git merges the cache without a conflict. After a run that reads
464
+ every file, adhere prunes the cache: it deletes answers about content no file
465
+ has and answers to rule texts no rule asks, and folds what is left into one
466
+ file per content. A run narrowed by `--filter` does not prune. To keep the
467
+ cache out of diffs, mark it as generated in `.gitattributes`:
468
+
469
+ ```text
470
+ .adhere/cache/** linguist-generated -diff
471
+ ```
472
+
473
+ Anyone who can commit can also write a cache file saying that code passes.
474
+ Where lint gates a merge, lint a pull request against the cache as merged,
475
+ not as the pull request has it; only the files it changes are judged again:
476
+
477
+ ```sh
478
+ rm -rf .adhere/cache && git checkout origin/main -- .adhere/cache
479
+ adhere lint --yes
480
+ ```
368
481
 
369
482
  The API key is read only when a request is about to be sent. A run where
370
483
  every file is cached needs no key and no network.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Lints a repository against the rules in .adhere/, judged by Jev.",
6
6
  "license": "MIT",
@@ -11,6 +11,12 @@
11
11
  "bin": {
12
12
  "adhere": "bin/adhere.js"
13
13
  },
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "presets",
18
+ "skills"
19
+ ],
14
20
  "type": "module",
15
21
  "imports": {
16
22
  "#*": "./src/*"
@@ -18,12 +24,6 @@
18
24
  "exports": {
19
25
  ".": "./src/index.ts"
20
26
  },
21
- "files": [
22
- "bin",
23
- "src",
24
- "presets",
25
- "skills"
26
- ],
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
@@ -68,10 +68,10 @@
68
68
  }
69
69
  },
70
70
  "optionalDependencies": {
71
- "@drkmttr/adhere-darwin-arm64": "0.7.3",
72
- "@drkmttr/adhere-darwin-x64": "0.7.3",
73
- "@drkmttr/adhere-linux-arm64": "0.7.3",
74
- "@drkmttr/adhere-linux-x64": "0.7.3",
75
- "@drkmttr/adhere-win32-x64": "0.7.3"
71
+ "@drkmttr/adhere-darwin-arm64": "0.8.0",
72
+ "@drkmttr/adhere-darwin-x64": "0.8.0",
73
+ "@drkmttr/adhere-linux-arm64": "0.8.0",
74
+ "@drkmttr/adhere-linux-x64": "0.8.0",
75
+ "@drkmttr/adhere-win32-x64": "0.8.0"
76
76
  }
77
77
  }
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A call over the network, such as an HTTP request, a database query, or a third-party API, must carry a timeout and a retry schedule. Calls through the platform FileSystem and Path services are local and are not in scope.
2
+ description: A call over the network, such as an HTTP request, a database query, or a third-party API, must be made with a timeout and a retry schedule, never bare. Calls through the platform FileSystem and Path services are local and are not in scope.
3
3
  ---
4
4
 
5
5
  ```ts must
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A test that depends on time must use TestClock. Real sleeps and it.live must be used only when real time is required.
2
+ description: A test that depends on time must use TestClock, never a real sleep or it.live, unless the test needs real time.
3
3
  ---
4
4
 
5
5
  ```ts must
@@ -103,14 +103,15 @@ export default {
103
103
  } satisfies Config;
104
104
  ```
105
105
 
106
- Add `.adhere/cache/` to `.gitignore`. Uncached files need a TypeSafe AI API
106
+ Commit `.adhere/cache/` with the code, so the team and CI reuse its judgments
107
+ instead of paying for them again. Uncached files need a TypeSafe AI API
107
108
  key: one saved by `adhere login`, or `TYPESAFE_API_KEY`, which takes
108
109
  precedence; in this org it comes from SOPS via `just shell`.
109
110
 
110
111
  ## 5. Run and calibrate
111
112
 
112
113
  ```sh
113
- adhere validate # decodes the config and rules, asks Jev about contradictions
114
+ adhere validate # decodes the config and rules; reports their wording, rules a linter could check, and contradictions
114
115
  adhere lint # or: adhere lint --preset effect
115
116
  adhere lint --threshold 0.9 # re-reads the cache, sends nothing
116
117
  ```
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  executeAudit,
9
9
  type FileDone,
10
10
  planAudit,
11
+ pruneCache,
11
12
  render,
12
13
  } from "#workflows/audit.ts";
13
14
  import {
@@ -20,16 +21,20 @@ import {
20
21
  import type { Overrides } from "#config.ts";
21
22
  import { findContradictions, formatContradictions } from "#contradictions.ts";
22
23
  import { initProject } from "#init.ts";
24
+ import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
23
25
  import { type PresetName, presetNames } from "#presets.ts";
24
26
  import { globalRuleSet } from "#rules.ts";
27
+ import { formatStrays, straysAmong } from "#wording.ts";
25
28
  import {
26
29
  Console,
27
30
  Effect,
28
31
  Exit,
29
32
  Layer,
33
+ LogLevel,
30
34
  Option,
31
35
  Path,
32
36
  Redacted,
37
+ References,
33
38
  Runtime,
34
39
  Schema,
35
40
  Stdio,
@@ -118,8 +123,9 @@ const toStderr = (text: string) =>
118
123
  * anything else it stays quiet, so a log gets the plan and the report rather
119
124
  * than a line per file. A failed run keeps its last count on screen.
120
125
  */
121
- const counterFor = (plan: AuditPlan) => {
122
- const live = process.stderr.isTTY === true;
126
+ const counterFor = (plan: AuditPlan, logging: LogLevel.LogLevel) => {
127
+ // Log lines at debug or below would land between redraws and garble the counter.
128
+ const live = process.stderr.isTTY === true && LogLevel.isGreaterThan(logging, "Debug");
123
129
  let progress: Progress = { files: 0, requests: 0, findings: 0 };
124
130
  const draw = () => (live ? toStderr(`\r\u001b[2K${progressLine(progress, plan)}`) : Effect.void);
125
131
  return {
@@ -170,11 +176,13 @@ export const lintCommand = Command.make(
170
176
  return yield* Cancelled.make({});
171
177
  }
172
178
  }
173
- const counter = counterFor(plan);
179
+ const counter = counterFor(plan, yield* References.MinimumLogLevel);
174
180
  yield* counter.start;
175
181
  const result = yield* executeAudit(plan, counter.update).pipe(
176
182
  Effect.onExit((exit) => counter.finish(Exit.isFailure(exit))),
177
183
  );
184
+ // Only a run that read every file knows what the cache still needs.
185
+ if (input.filter.length === 0) yield* pruneCache(plan);
178
186
  const color = yield* stdio.stdoutIsTerminal;
179
187
  const path = yield* Path.Path;
180
188
  // One write: separate Console.log calls have interleaved out of order here.
@@ -201,31 +209,44 @@ export const lintCommand = Command.make(
201
209
 
202
210
  /** Jev, with the key `lint` uses, and the config it reads the model and threshold from. */
203
211
  export const validateLayer = (overrides: Overrides) =>
204
- JevLive.pipe(
205
- Layer.provide([FetchHttpClient.layer, CredentialsLive]),
206
- Layer.provideMerge(AdhereConfigLive(overrides)),
207
- );
212
+ Layer.merge(
213
+ JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
214
+ AuditCacheLive,
215
+ ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
208
216
 
209
217
  /**
210
- * `adhere validate`: everything `lint` loads, then Jev on whether any two rules
211
- * that apply to the same files contradict. A config, rule file, or preset that
212
- * does not decode refuses here the way it would refuse `lint`.
218
+ * `adhere validate`: everything `lint` loads, then how each rule's wording
219
+ * differs from the README's rule writing tips, then the linter check's tallies
220
+ * from lint, then Jev on whether any two rules that apply to the same files
221
+ * contradict. Only a contradiction fails the run: the tips and the linter
222
+ * check are advice. A config, rule file, or preset that does not decode
223
+ * refuses here the way it would refuse `lint`.
213
224
  */
214
225
  export const validateCommand = Command.make("validate", { preset }, () =>
215
226
  Effect.gen(function* () {
216
227
  const config = yield* AdhereConfig;
217
228
  const path = yield* Path.Path;
218
- const entries = config.scopedRules ?? globalRuleSet(config.rules, path.resolve());
219
- const contradictions = yield* findContradictions(entries, config.threshold);
229
+ const root = path.resolve();
230
+ const entries = config.scopedRules ?? globalRuleSet(config.rules, root);
220
231
  const loaded = `${entries.length} ${entries.length === 1 ? "rule" : "rules"} loaded.`;
221
- yield* Console.log([loaded, formatContradictions(contradictions)].join("\n"));
232
+ const tallied = yield* tallyProjectRules(entries, config.model);
233
+ // The wording and the tallies need no request, so they show before Jev is asked anything.
234
+ yield* Console.log(
235
+ [
236
+ loaded,
237
+ ...formatStrays(straysAmong(entries), root),
238
+ ...formatLinterCheck(tallied, root),
239
+ ].join("\n"),
240
+ );
241
+ const contradictions = yield* findContradictions(entries, config.threshold);
242
+ yield* Console.log(formatContradictions(contradictions));
222
243
  if (contradictions.length > 0) {
223
244
  return yield* ContradictionsReported.make({ count: contradictions.length });
224
245
  }
225
246
  }),
226
247
  ).pipe(
227
248
  Command.withDescription(
228
- "Load the config, rule files, and presets the way lint does, then ask Jev whether any two rules that apply to the same files contradict each other.",
249
+ "Load the config, rule files, and presets the way lint does, check each rule's wording against the README's rule writing tips, report the rules lint's linter check flags, then ask Jev whether any two rules that apply to the same files contradict each other.",
229
250
  ),
230
251
  Command.provide((input) => validateLayer({ presets: chosenPresets(input.preset) })),
231
252
  );
@@ -312,7 +333,10 @@ export const cli = Command.make("adhere").pipe(
312
333
  Command.withDescription("Lint a repository against the rules in .adhere/, judged by Jev."),
313
334
  Command.withExamples([
314
335
  { command: "adhere init", description: "Scaffold .adhere/config.ts and two example rules" },
315
- { command: "adhere validate", description: "Check the config and rules without calling Jev" },
336
+ {
337
+ command: "adhere validate",
338
+ description: "Check the rules' wording, and ask Jev whether any contradict",
339
+ },
316
340
  { command: "adhere login", description: "Save your TypeSafe AI API key for later runs" },
317
341
  { command: "adhere lint", description: "Audit the working directory" },
318
342
  {