@drkmttr/adhere 0.5.0 → 0.6.1

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 (28) hide show
  1. package/README.md +55 -17
  2. package/package.json +6 -6
  3. package/presets/effect/basics/fn-for-named-effects.md +1 -1
  4. package/presets/effect/basics/gen-for-sequencing.md +1 -1
  5. package/presets/effect/basics/instrument-with-pipe.md +1 -1
  6. package/presets/effect/config/business-logic-depends-on-config-service.md +1 -1
  7. package/presets/effect/config/secrets-are-redacted.md +1 -1
  8. package/presets/effect/config/validate-with-schema.md +1 -1
  9. package/presets/effect/data/brand-meaningful-primitives.md +1 -1
  10. package/presets/effect/data/decode-json-with-schema.md +1 -1
  11. package/presets/effect/data/records-are-schema-classes.md +1 -1
  12. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  13. package/presets/effect/errors/catch-defects-at-boundaries-only.md +1 -1
  14. package/presets/effect/errors/defects-for-bugs.md +1 -1
  15. package/presets/effect/errors/domain-errors-are-tagged.md +1 -1
  16. package/presets/effect/errors/wrap-external-errors.md +1 -1
  17. package/presets/effect/platform/os-access-through-platform-services.md +1 -1
  18. package/presets/effect/services/fresh-layer-per-test.md +1 -1
  19. package/presets/effect/services/memoize-parameterized-layers.md +1 -1
  20. package/presets/effect/services/methods-have-no-requirements.md +1 -1
  21. package/presets/effect/services/provide-at-entry.md +1 -1
  22. package/presets/effect/services/test-layers-are-in-memory.md +1 -1
  23. package/presets/effect/testing/test-clock-for-time.md +1 -1
  24. package/src/cli.ts +73 -3
  25. package/src/services/Jev.http.ts +79 -13
  26. package/src/services/Jev.ts +144 -35
  27. package/src/workflows/audit.ts +210 -79
  28. package/src/workflows/format.ts +42 -1
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # adhere
2
2
 
3
3
  A linter for rules a normal linter cannot check. A rule is a one-sentence
4
- description plus correct reference code, code to avoid, or both. For each
4
+ description like "business logic should live in services", plus correct
5
+ reference code / code to avoid / or both. For each
5
6
  source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
6
7
  model) whether the file breaks each rule, gets a calibrated probability per
7
8
  rule, and reports the ones above a threshold with the line Jev points at. The
@@ -9,18 +10,23 @@ report uses the same frame as `vp lint`.
9
10
 
10
11
  Deterministic rules (substring matches, type checks) belong in a normal linter.
11
12
 
13
+ A preset for effect is included which can be run without setup:
14
+
15
+ ```sh
16
+ TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset effect
17
+ ```
18
+
12
19
  ## Install
13
20
 
14
21
  ```sh
15
- npm install --global @drkmttr/adhere # or: bun add --global @drkmttr/adhere
22
+ bun add --global @drkmttr/adhere # or: npm install --global @drkmttr/adhere
16
23
  ```
17
24
 
18
25
  To pin the version in a repo, for CI or scripts, add it as a dev dependency
19
- and run `npx adhere`. To try it without installing, run
20
- `npx @drkmttr/adhere` or `bunx @drkmttr/adhere`.
26
+ and run `npx adhere`.
21
27
 
22
28
  `adhere` is a prebuilt executable for macOS and Linux on arm64 and x64 and for
23
- Windows on x64. It needs Node to start, and no Bun.
29
+ Windows on x64. .
24
30
 
25
31
  adhere sends each file it judges to Jev at `api.typesafe.ai`, authenticated
26
32
  with a TypeSafe AI API key: the one `adhere login` saved, or
@@ -32,7 +38,7 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
32
38
  adhere init # .adhere/config.ts and two example rules
33
39
  echo ".adhere/cache/" >> .gitignore
34
40
  adhere login # save your TypeSafe AI API key, once
35
- adhere validate # check the rules, and ask Jev whether any contradict
41
+ adhere validate # Checks your rules for contradictions using Jev
36
42
  adhere lint # audit the working directory
37
43
  ```
38
44
 
@@ -68,6 +74,7 @@ refusal prints its reason.
68
74
  adhere lint # audit the working directory
69
75
  adhere lint --preset effect # add a built-in rule set; the config becomes optional
70
76
  adhere lint --threshold 0.8 # replace the config's threshold; per-rule thresholds still apply
77
+ adhere lint --yes # send the requests without asking first
71
78
  adhere validate # load the config and rules, ask Jev whether any contradict
72
79
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
73
80
  adhere login # save a TypeSafe AI API key for later runs
@@ -79,6 +86,24 @@ Bare `adhere` prints the help, which lists the commands, and
79
86
  `adhere <command> --help` lists a command's flags. `adhere --completions <shell>`
80
87
  prints a completion script.
81
88
 
89
+ ### Before and during a run
90
+
91
+ Before it sends anything, `lint` says on stderr what it found and what judging
92
+ takes:
93
+
94
+ ```text
95
+ 200 files and 14 rules: 2800 checks, 1400 cached.
96
+ Judging the other 1400 takes 200 requests to Jev, plus 1 or more for each file with a finding.
97
+ ? Send 200 requests to Jev? › (Y/n)
98
+ ```
99
+
100
+ It asks only with a terminal on stdin and stdout, and never when the cache
101
+ answers every check; `--yes` sends without asking. While it runs, a counter on
102
+ stderr shows the files done, the requests sent, and the findings so far, and
103
+ the report goes to stdout. A request Jev refuses stops the run with the file,
104
+ the HTTP status, and Jev's reason. Files finished before it are cached, so
105
+ running again continues from there.
106
+
82
107
  ### What gets read
83
108
 
84
109
  `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
@@ -141,7 +166,7 @@ export default {
141
166
  rules: {
142
167
  "data/brand-meaningful-primitives": {
143
168
  description:
144
- "A primitive with semantic meaning, such as an id, email, URL, port, or count, is a branded schema.",
169
+ "A primitive with semantic meaning, such as an id, email, URL, port, or count, should be a branded schema.",
145
170
  reference: `
146
171
  const UserId = Schema.String.pipe(Schema.brand("UserId"))
147
172
  type UserId = typeof UserId.Type
@@ -254,25 +279,38 @@ beats all of the above for that rule.
254
279
 
255
280
  ## How a file is judged
256
281
 
257
- 1. One Jev request per file, with the file's numbered lines and every rule as
258
- the state, and one `noul` (yes/no probability) question per rule: whether
259
- the file diverges from the reference or, for a rule with only code to
260
- avoid, whether it contains that code. Every question shares the state cost
261
- of the request.
282
+ 1. One Jev request per file. The state is the file's numbered lines and
283
+ nothing else. Each rule is one `noul` (yes/no probability) question that
284
+ carries the rule's description, reference, and code to avoid: whether the
285
+ file diverges from the reference or, for a rule with only code to avoid,
286
+ whether it contains that code. Criteria for each answer draw the line at
287
+ the rule's scope, so a file with no code the rule is about is a no. Since
288
+ no rule sits in the shared state, a rule's probability depends only on the
289
+ file and that rule, not on which other rules share the request. Every
290
+ question shares the state cost of the request.
262
291
  2. A second request only when at least one rule's probability is above its
263
292
  threshold: one `choice` question per flagged rule over the file's non-blank
264
293
  lines, which yields the line to report. A file with more than 255 non-blank
265
294
  lines is located in two steps (a block of 20 lines, then a line inside it).
266
- Files longer than 5100 lines are skipped and counted in the summary.
295
+ 3. Jev reads at most 32k tokens of state and one question together, and 64k
296
+ tokens in a request. adhere estimates tokens from the JSON it sends, at
297
+ about three bytes a token. When a file's questions would not fit in one
298
+ request, they are split across several. A file whose code and longest
299
+ question would not fit together, or that has more than 5100 lines, is
300
+ skipped and counted in the summary, and the rest of the run goes on. So is
301
+ a file Jev itself counts as over its context, which text denser than the
302
+ estimate, such as CJK, can cause.
267
303
 
268
304
  ## Cache
269
305
 
270
306
  Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
271
307
  per file. An entry stores the file's content hash and, per rule, the
272
- probability, the located line, and a fingerprint of the rule's text and model.
273
- A changed file re-judges every rule for that file. An edited rule re-judges
274
- only that rule. A lowered threshold locates cached judgments that are newly
275
- above it without judging again. Entries depend on content, not on the
308
+ probability, the located line, and a fingerprint of the model and the
309
+ question asked for the rule, which carries the rule's text. A changed file
310
+ re-judges every rule for that file. An edited rule re-judges only that rule,
311
+ and an adhere that asks its questions differently re-judges every rule once.
312
+ A lowered threshold locates cached judgments that are newly above it without
313
+ judging again. Entries depend on content, not on the
276
314
  machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
277
315
 
278
316
  The API key is read only when a request is about to be sent. A run where
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "description": "Lints a repository against reference code in .adhere/, judged by Jev.",
6
6
  "repository": {
@@ -67,10 +67,10 @@
67
67
  }
68
68
  },
69
69
  "optionalDependencies": {
70
- "@drkmttr/adhere-darwin-arm64": "0.5.0",
71
- "@drkmttr/adhere-darwin-x64": "0.5.0",
72
- "@drkmttr/adhere-linux-arm64": "0.5.0",
73
- "@drkmttr/adhere-linux-x64": "0.5.0",
74
- "@drkmttr/adhere-win32-x64": "0.5.0"
70
+ "@drkmttr/adhere-darwin-arm64": "0.6.1",
71
+ "@drkmttr/adhere-darwin-x64": "0.6.1",
72
+ "@drkmttr/adhere-linux-arm64": "0.6.1",
73
+ "@drkmttr/adhere-linux-x64": "0.6.1",
74
+ "@drkmttr/adhere-win32-x64": "0.6.1"
75
75
  }
76
76
  }
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A named function that returns an Effect is defined with Effect.fn so the call site is traced.
2
+ description: A named function that returns an Effect should be defined with Effect.fn so the call site is traced.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Sequential effectful steps are written with Effect.gen and yield*, not nested flatMap or callback chains.
2
+ description: Sequential effectful steps should be written with Effect.gen and yield*, not nested flatMap or callback chains.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Timeouts, retries, logging, and spans are attached with .pipe, not written into the body of the effect.
2
+ description: Timeouts, retries, logging, and spans should be attached with .pipe, not written into the body of the effect.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Business logic depends on a config service. Config primitives are read only inside that service's layer.
2
+ description: Business logic depends on a config service. Config primitives should be read only inside that service's layer.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A token, password, or key is read with Config.redacted.
2
+ description: A token, password, or key should be read with Config.redacted.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A config value with constraints is read with Config.schema, not mapped and checked by hand.
2
+ description: A config value with constraints should be read with Config.schema, not mapped and checked by hand.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A primitive with semantic meaning, such as an id, email, URL, port, or count, is a branded schema.
2
+ description: A primitive with semantic meaning, such as an id, email, URL, port, or count, should be a branded schema.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: JSON crossing a boundary is decoded with Schema.fromJsonString, not JSON.parse followed by a cast.
2
+ description: JSON crossing a boundary should be decoded with Schema.fromJsonString, not JSON.parse followed by a cast.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Domain records are defined with Schema.Class, not with a plain interface or type alias.
2
+ description: Domain records should be defined with Schema.Class, not with a plain interface or type alias.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Structured variants are Schema.TaggedClass in a Schema.Union and are matched with Match.tag and Match.exhaustive.
2
+ description: Structured variants should be Schema.TaggedClass in a Schema.Union and matched with Match.tag and Match.exhaustive.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Defects are caught only at a system boundary for logging or shutdown, never in business logic.
2
+ description: Defects should be caught only at a system boundary for logging or shutdown, never in business logic.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A typed error is for a failure the caller can handle. A bug or invariant violation is a defect.
2
+ description: A typed error should be for a failure the caller can handle. A bug or invariant violation should be a defect.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A domain failure is a Schema.TaggedError with its own tag.
2
+ description: A domain failure should be a Schema.TaggedError with its own tag.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: An error from an external library is wrapped in a tagged error with a Schema.Defect field, not passed through raw.
2
+ description: An error from an external library should be wrapped in a tagged error with a Schema.Defect field, not passed through raw.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: File, path, process, terminal, and HTTP access goes through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, not through node: builtins, Bun globals, or fetch. The Bun implementations are provided once at the entry point with BunServices.layer.
2
+ description: File, path, process, terminal, and HTTP access goes through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, not through node: builtins, Bun globals, or fetch. The Bun implementations should be provided once at the entry point with BunServices.layer.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Each it.effect provides its own layer. it.layer is used only to share an expensive resource across a suite.
2
+ description: Each it.effect provides its own layer. it.layer should be used only to share an expensive resource across a suite.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: The result of a parameterized layer constructor is stored in a module constant before it is used in more than one place.
2
+ description: The result of a parameterized layer constructor should be stored in a module constant before it is used in more than one place.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Service method signatures have no requirements. Dependencies are acquired inside the layer, not declared on the method.
2
+ description: Service method signatures have no requirements. Dependencies should be acquired inside the layer, not declared on the method.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Layers are provided once at the program entry. A module that is not an entry point does not call Effect.provide.
2
+ description: Layers should be provided once at the program entry. A module that is not an entry point does not call Effect.provide.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A test implementation of a service is built with Layer.sync or Layer.succeed over in-memory state.
2
+ description: A test implementation of a service should be built with Layer.sync or Layer.succeed over in-memory state.
3
3
  ---
4
4
 
5
5
  ```ts
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A test that depends on time uses TestClock. Real sleeps and it.live are used only when real time is required.
2
+ description: A test that depends on time uses TestClock. Real sleeps and it.live should be used only when real time is required.
3
3
  ---
4
4
 
5
5
  ```ts
package/src/cli.ts CHANGED
@@ -3,7 +3,20 @@ import { AuditCacheLive } from "#services/AuditCache.ts";
3
3
  import { Credentials, CredentialsLive, CredentialsUnavailable } from "#services/Credentials.ts";
4
4
  import { JevLive } from "#services/Jev.http.ts";
5
5
  import { SourceWalkerLive } from "#services/SourceWalker.ts";
6
- import { render, runAudit } from "#workflows/audit.ts";
6
+ import {
7
+ type AuditPlan,
8
+ executeAudit,
9
+ type FileDone,
10
+ planAudit,
11
+ render,
12
+ } from "#workflows/audit.ts";
13
+ import {
14
+ describePlan,
15
+ type Progress,
16
+ progressAfter,
17
+ progressLine,
18
+ sendQuestion,
19
+ } from "#workflows/format.ts";
7
20
  import type { Overrides } from "#config.ts";
8
21
  import { findContradictions, formatContradictions } from "#contradictions.ts";
9
22
  import { initProject } from "#init.ts";
@@ -12,6 +25,7 @@ import { globalRuleSet } from "#rules.ts";
12
25
  import {
13
26
  Console,
14
27
  Effect,
28
+ Exit,
15
29
  Layer,
16
30
  Option,
17
31
  Path,
@@ -54,6 +68,44 @@ const threshold = Flag.float("threshold").pipe(
54
68
  ),
55
69
  );
56
70
 
71
+ const yes = Flag.boolean("yes").pipe(
72
+ Flag.withAlias("y"),
73
+ Flag.withDefault(false),
74
+ Flag.withDescription(
75
+ "Send the requests without asking first. lint only asks with a terminal on stdin and stdout.",
76
+ ),
77
+ );
78
+
79
+ class Cancelled extends Schema.TaggedError<Cancelled>()("Cancelled", {}) {
80
+ readonly [Runtime.errorReported] = false;
81
+ }
82
+
83
+ /** Text on stderr, beside the report on stdout. */
84
+ const toStderr = (text: string) =>
85
+ Effect.sync(() => {
86
+ process.stderr.write(text);
87
+ });
88
+
89
+ /**
90
+ * A counter on stderr, redrawn in place on a terminal as files finish. On
91
+ * anything else it stays quiet, so a log gets the plan and the report rather
92
+ * than a line per file. A failed run keeps its last count on screen.
93
+ */
94
+ const counterFor = (plan: AuditPlan) => {
95
+ const live = process.stderr.isTTY === true;
96
+ let progress: Progress = { files: 0, requests: 0, findings: 0 };
97
+ const draw = () => (live ? toStderr(`\r\u001b[2K${progressLine(progress, plan)}`) : Effect.void);
98
+ return {
99
+ start: Effect.suspend(draw),
100
+ update: (done: FileDone) =>
101
+ Effect.suspend(() => {
102
+ progress = progressAfter(progress, done);
103
+ return draw();
104
+ }),
105
+ finish: (failed: boolean) => (live ? toStderr(failed ? "\n" : "\r\u001b[2K") : Effect.void),
106
+ };
107
+ };
108
+
57
109
  const force = Flag.boolean("force").pipe(
58
110
  Flag.withDefault(false),
59
111
  Flag.withDescription("Overwrite existing scaffold files."),
@@ -70,10 +122,28 @@ export const auditLayer = (overrides: Overrides) =>
70
122
  ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
71
123
 
72
124
  /** `adhere lint`: the audit. */
73
- export const lintCommand = Command.make("lint", { preset, threshold }, () =>
125
+ export const lintCommand = Command.make("lint", { preset, threshold, yes }, (input) =>
74
126
  Effect.gen(function* () {
75
- const result = yield* runAudit;
76
127
  const stdio = yield* Stdio.Stdio;
128
+ const plan = yield* planAudit;
129
+ yield* toStderr(`${describePlan(plan).join("\n")}\n`);
130
+ // A prompt draws on stdout, so it needs a terminal at both ends: none when
131
+ // the report is redirected, or in CI.
132
+ const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
133
+ if (plan.requests > 0 && interactive && !input.yes) {
134
+ const send = yield* Prompt.run(
135
+ Prompt.confirm({ message: sendQuestion(plan), initial: true }),
136
+ );
137
+ if (!send) {
138
+ yield* toStderr("Nothing sent.\n");
139
+ return yield* Cancelled.make({});
140
+ }
141
+ }
142
+ const counter = counterFor(plan);
143
+ yield* counter.start;
144
+ const result = yield* executeAudit(plan, counter.update).pipe(
145
+ Effect.onExit((exit) => counter.finish(Exit.isFailure(exit))),
146
+ );
77
147
  const color = yield* stdio.stdoutIsTerminal;
78
148
  const path = yield* Path.Path;
79
149
  // One write: separate Console.log calls have interleaved out of order here.
@@ -2,10 +2,12 @@ import { AdhereConfig } from "#services/AdhereConfig.ts";
2
2
  import { Credentials } from "#services/Credentials.ts";
3
3
  import {
4
4
  blockBody,
5
+ type Body,
5
6
  type ComparedRule,
6
7
  conflictBody,
7
8
  contradictBody,
8
9
  Jev,
10
+ JevOverflow,
9
11
  JevUnavailable,
10
12
  judgeBody,
11
13
  type Lines,
@@ -14,10 +16,11 @@ import {
14
16
  needsBlocks,
15
17
  type Pair,
16
18
  pairProbability,
19
+ requestsOf,
17
20
  type Rules,
18
21
  } from "#services/Jev.ts";
19
- import { Effect, Layer, Record, Schedule, Schema } from "effect";
20
- import { HttpClient, HttpClientResponse } from "effect/unstable/http";
22
+ import { type Cause, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
23
+ import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
21
24
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
22
25
 
23
26
  const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
@@ -31,6 +34,47 @@ const ChoiceAnswers = Schema.Struct({
31
34
 
32
35
  const refused = (message: string) => JevUnavailable.make({ message });
33
36
 
37
+ /** Jev's error body, on one line and cut short, or nothing when it sent none. */
38
+ const detailOf = (body: string): string => {
39
+ const text = body.trim().replace(/\s+/g, " ");
40
+ return text.length === 0 ? "" : `: ${text.slice(0, 300)}`;
41
+ };
42
+
43
+ /** What Jev answers, with a 400, to a request over its context. */
44
+ const Overflowed = Schema.fromJsonString(
45
+ Schema.Struct({ detail: Schema.Struct({ error_type: Schema.Literal("max_tokens_exceeded") }) }),
46
+ );
47
+ const isOverflow = (body: string): boolean =>
48
+ Option.isSome(Schema.decodeUnknownOption(Overflowed)(body));
49
+
50
+ /**
51
+ * A request that failed. A 400 saying the request is over Jev's context is a
52
+ * `JevOverflow`, which skips the file. Any other answer outside 2xx refuses
53
+ * the run with its status and what Jev said, since the status alone does not
54
+ * say why, and a request that got no answer refuses with the cause.
55
+ */
56
+ const failed = (problem: HttpClientError.HttpClientError | Cause.TimeoutError) => {
57
+ if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
58
+ return Effect.fail(refused(`System One request failed: ${problem.message}`));
59
+ }
60
+ const { response } = problem.reason;
61
+ return Effect.flatMap(
62
+ Effect.orElseSucceed(response.text, () => ""),
63
+ (body): Effect.Effect<never, JevOverflow | JevUnavailable> =>
64
+ response.status === 400 && isOverflow(body)
65
+ ? Effect.fail(JevOverflow.make())
66
+ : Effect.fail(refused(`Jev answered HTTP ${response.status}${detailOf(body)}`)),
67
+ );
68
+ };
69
+
70
+ /** Comparing rules has no file to skip, so a request over Jev's context refuses instead. */
71
+ const refuseOverflow = <A>(
72
+ self: Effect.Effect<A, JevOverflow | JevUnavailable>,
73
+ ): Effect.Effect<A, JevUnavailable> =>
74
+ Effect.catchTag(self, "JevOverflow", () =>
75
+ Effect.fail(refused("Comparing the rules took a request over Jev's context")),
76
+ );
77
+
34
78
  export const JevLive = Layer.effect(Jev)(
35
79
  Effect.gen(function* () {
36
80
  const config = yield* AdhereConfig;
@@ -44,7 +88,7 @@ export const JevLive = Layer.effect(Jev)(
44
88
  }),
45
89
  );
46
90
 
47
- const ask = <A>(body: unknown, Answers: Schema.Codec<A, unknown, never, never>) =>
91
+ const ask = <A>(body: Body, Answers: Schema.Codec<A, unknown, never, never>) =>
48
92
  Effect.gen(function* () {
49
93
  // Read here, not in the layer: a run with nothing pending needs no key.
50
94
  const apiKey = yield* credentials.apiKey.pipe(
@@ -56,9 +100,7 @@ export const JevLive = Layer.effect(Jev)(
56
100
  ).pipe(Effect.orDie);
57
101
  const response = yield* client
58
102
  .execute(HttpClientRequest.bearerToken(request, apiKey))
59
- .pipe(
60
- Effect.mapError((problem) => refused(`System One request failed: ${problem.message}`)),
61
- );
103
+ .pipe(Effect.catch(failed));
62
104
  return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
63
105
  Effect.mapError((problem) =>
64
106
  refused(`System One response did not decode: ${problem.message}`),
@@ -66,8 +108,27 @@ export const JevLive = Layer.effect(Jev)(
66
108
  );
67
109
  });
68
110
 
111
+ /** Every answer to a body, over as many requests as Jev's context needs. */
112
+ const answersTo = <A>(
113
+ body: Body,
114
+ Answers: Schema.Codec<
115
+ { readonly answers: Readonly<Record<string, A>> },
116
+ unknown,
117
+ never,
118
+ never
119
+ >,
120
+ ) =>
121
+ Effect.map(
122
+ Effect.forEach(requestsOf(body), (request) => ask(request, Answers)),
123
+ (responses) =>
124
+ responses.reduce<Record<string, A>>(
125
+ (answers, response) => ({ ...answers, ...response.answers }),
126
+ {},
127
+ ),
128
+ );
129
+
69
130
  const judge = Effect.fn("Jev.judge")(function* (lines: Lines, rules: Rules) {
70
- const { answers } = yield* ask(judgeBody(config.model, lines, rules), NoulAnswers);
131
+ const answers = yield* answersTo(judgeBody(config.model, lines, rules), NoulAnswers);
71
132
  return Record.map(answers, (answer) => answer.noul);
72
133
  });
73
134
 
@@ -76,17 +137,21 @@ export const JevLive = Layer.effect(Jev)(
76
137
 
77
138
  const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
78
139
  const blocks = needsBlocks(lines)
79
- ? chosen((yield* ask(blockBody(config.model, lines, rules), ChoiceAnswers)).answers)
140
+ ? chosen(yield* answersTo(blockBody(config.model, lines, rules), ChoiceAnswers))
80
141
  : undefined;
81
- const { answers } = yield* ask(locateBody(config.model, lines, rules, blocks), ChoiceAnswers);
82
- return chosen(answers);
142
+ return chosen(
143
+ yield* answersTo(locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
144
+ );
83
145
  });
84
146
 
85
147
  const conflicts = Effect.fn("Jev.conflicts")(function* (
86
148
  rules: ReadonlyArray<ComparedRule>,
87
149
  partners: ReadonlyArray<ReadonlyArray<number>>,
88
150
  ) {
89
- const { answers } = yield* ask(conflictBody(config.model, rules, partners), ChoiceAnswers);
151
+ const answers = yield* answersTo(
152
+ conflictBody(config.model, rules, partners),
153
+ ChoiceAnswers,
154
+ ).pipe(refuseOverflow);
90
155
  return namedPairs(answers, partners);
91
156
  });
92
157
 
@@ -97,8 +162,9 @@ export const JevLive = Layer.effect(Jev)(
97
162
  return yield* Effect.forEach(
98
163
  pairs,
99
164
  (pair) =>
100
- Effect.map(ask(contradictBody(config.model, rules, pair), NoulAnswers), ({ answers }) =>
101
- pairProbability(answers, pair),
165
+ Effect.map(
166
+ ask(contradictBody(config.model, rules, pair), NoulAnswers).pipe(refuseOverflow),
167
+ ({ answers }) => pairProbability(answers, pair),
102
168
  ),
103
169
  { concurrency: 8 },
104
170
  );
@@ -8,17 +8,23 @@ export class JevUnavailable extends Schema.TaggedError<JevUnavailable>()("JevUna
8
8
  message: Schema.String,
9
9
  }) {}
10
10
 
11
+ /**
12
+ * Jev's refusal of a request over its context. `fits` keeps such files from
13
+ * being sent, but by an estimate; this is Jev's own count disagreeing.
14
+ */
15
+ export class JevOverflow extends Schema.TaggedError<JevOverflow>()("JevOverflow", {}) {}
16
+
11
17
  export class Jev extends Context.Service<
12
18
  Jev,
13
19
  {
14
20
  readonly judge: (
15
21
  lines: Lines,
16
22
  rules: Rules,
17
- ) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
23
+ ) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
18
24
  readonly locate: (
19
25
  lines: Lines,
20
26
  rules: Rules,
21
- ) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
27
+ ) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
22
28
  /**
23
29
  * Per rule, the partner Jev names as impossible to follow in the same code,
24
30
  * as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
@@ -51,7 +57,27 @@ export type Pair = readonly [number, number];
51
57
  /** A `choice` question accepts at most this many criteria. */
52
58
  const CHOICE_LIMIT = 255;
53
59
  const LINES_PER_BLOCK = 20;
54
- export const MAX_LINES = CHOICE_LIMIT * LINES_PER_BLOCK;
60
+ const MAX_LINES = CHOICE_LIMIT * LINES_PER_BLOCK;
61
+
62
+ /**
63
+ * Jev 1.13's context, from its model card: the state with its longest
64
+ * question, and the state with every question in the request.
65
+ */
66
+ const QUESTION_CONTEXT = 32_000;
67
+ const REQUEST_CONTEXT = 64_000;
68
+ /** Room for what Jev wraps around a request, which `tokensOf` does not see. */
69
+ const WRAPPER = 1_000;
70
+
71
+ const encoder = new TextEncoder();
72
+
73
+ /**
74
+ * An estimate, meant to run high, of the tokens Jev reads for a value: one
75
+ * per three bytes of its JSON. Jev 1.13 reads numbered TypeScript at 3.2
76
+ * bytes a token, and this ran 12 to 16 percent high on the eval's requests.
77
+ * Text that packs more tokens into its bytes, such as CJK, can outrun it.
78
+ */
79
+ export const tokensOf = (value: unknown): number =>
80
+ Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
55
81
 
56
82
  export const snippetOf = (line: string): string => line.trim().slice(0, 120);
57
83
 
@@ -60,18 +86,25 @@ const isBlank = (line: string): boolean => line.trim().length === 0;
60
86
  export const needsBlocks = (lines: Lines): boolean =>
61
87
  lines.filter((line) => !isBlank(line)).length > CHOICE_LIMIT;
62
88
 
63
- /** A rule's text as Jev reads it. Code a rule lacks is left out, not sent empty. */
89
+ /** The file, numbered so a line question can name a line. The only thing the questions share. */
90
+ const stateOf = (lines: Lines) => ({
91
+ code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
92
+ });
93
+
94
+ /** The rule as its questions carry it: the description, and its code on either side. */
95
+ const ruleFields = (rule: Rule) => ({
96
+ rule: rule.description,
97
+ ...(rule.reference === undefined ? {} : { reference: rule.reference }),
98
+ ...(rule.avoid === undefined ? {} : { avoid: rule.avoid }),
99
+ });
100
+
101
+ /** A rule's text as a comparison of rules reads it. Code a rule lacks is left out, not sent empty. */
64
102
  const ruleState = ({ description, reference, avoid }: Rule) => ({
65
103
  description,
66
104
  ...(reference === undefined ? {} : { reference }),
67
105
  ...(avoid === undefined ? {} : { avoid }),
68
106
  });
69
107
 
70
- const stateOf = (lines: Lines, rules: Rules) => ({
71
- code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
72
- rules: Record.map(rules, (rule) => ruleState(rule)),
73
- });
74
-
75
108
  /**
76
109
  * The compared rules a question mentions, keyed by index: ids alone would
77
110
  * collide, since a nested rule can share its id with the rule it shadows.
@@ -87,28 +120,45 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
87
120
  return { rules: state };
88
121
  };
89
122
 
90
- const field = (id: RuleId, key: "avoid" | "description" | "reference") =>
91
- `state.rules["${id}"].${key}`;
92
-
93
123
  /**
94
- * A rule with a reference asks whether the file diverges from it, with the
95
- * code to avoid as an example when there is some. A rule without one asks
96
- * whether the file contains the code to avoid.
124
+ * Whether the file breaks the rule. The rule rides in the question, not the
125
+ * state, so each question reads its own rule and no other. A rule with a
126
+ * reference asks whether the file diverges from it, with the code to avoid
127
+ * as an example when there is some. A rule without one asks whether the file
128
+ * contains the code to avoid. The criteria draw the line at the rule's scope:
129
+ * a file with no code the rule is about is a no.
97
130
  */
98
- const judgeInstructions = (id: RuleId, rule: Rule): string =>
99
- rule.reference === undefined
100
- ? `Does state.code contain the pattern shown in ${field(id, "avoid")}, which ${field(id, "description")} rules out? Answer no if nothing in this file resembles it.`
101
- : `Does state.code diverge from the pattern shown in ${field(id, "reference")}${
102
- rule.avoid === undefined ? "" : `, for example by doing what ${field(id, "avoid")} shows`
103
- }, as described by ${field(id, "description")}? Answer no if the pattern does not apply to this file.`;
131
+ export const judgeQuestion = (rule: Rule) => {
132
+ const example = rule.avoid === undefined ? "" : ", for example by doing what `avoid` shows";
133
+ const [question, yes, no] =
134
+ rule.reference === undefined
135
+ ? [
136
+ "Does `code` contain the pattern shown in `avoid`, which `rule` rules out? Answer no if nothing in this file resembles it.",
137
+ "`code` contains the pattern shown in `avoid`",
138
+ "`code` has nothing like `avoid`",
139
+ ]
140
+ : [
141
+ `Does \`code\` diverge from the pattern shown in \`reference\`${example}, as described by \`rule\`? Answer no if the pattern does not apply to this file.`,
142
+ `\`code\` diverges from the pattern shown in \`reference\`${example}`,
143
+ "`code` follows the pattern",
144
+ ];
145
+ return {
146
+ type: "noul" as const,
147
+ instructions: { question, ...ruleFields(rule) },
148
+ criteria: {
149
+ true: `${yes}, in code that \`rule\` is about`,
150
+ false: `${no}, or has no code that \`rule\` is about`,
151
+ },
152
+ };
153
+ };
104
154
 
105
155
  /** What the offending line does, for the line and block questions. */
106
- const offense = (id: RuleId, rule: Rule): string =>
156
+ const offense = (rule: Rule): string =>
107
157
  rule.reference === undefined
108
- ? `shows the pattern in ${field(id, "avoid")}`
158
+ ? "shows the pattern in `avoid`"
109
159
  : rule.avoid === undefined
110
- ? `diverges from ${field(id, "reference")}`
111
- : `diverges from ${field(id, "reference")} or resembles ${field(id, "avoid")}`;
160
+ ? "diverges from `reference`"
161
+ : "diverges from `reference` or resembles `avoid`";
112
162
 
113
163
  const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
114
164
  const criteria: Record<string, string> = {};
@@ -121,11 +171,8 @@ const lineCriteria = (lines: Lines, from: number, to: number): Record<string, st
121
171
 
122
172
  export const judgeBody = (model: string, lines: Lines, rules: Rules) => ({
123
173
  model,
124
- state: stateOf(lines, rules),
125
- questions: Record.map(rules, (rule, id) => ({
126
- type: "noul" as const,
127
- instructions: judgeInstructions(id, rule),
128
- })),
174
+ state: stateOf(lines),
175
+ questions: Record.map(rules, (rule) => judgeQuestion(rule)),
129
176
  });
130
177
 
131
178
  export const locateBody = (
@@ -135,12 +182,15 @@ export const locateBody = (
135
182
  blocks?: Readonly<Record<RuleId, number>>,
136
183
  ) => ({
137
184
  model,
138
- state: stateOf(lines, rules),
185
+ state: stateOf(lines),
139
186
  questions: Record.map(rules, (rule, id) => {
140
187
  const block = blocks?.[id];
141
188
  return {
142
189
  type: "choice" as const,
143
- instructions: `Which line of state.code most clearly ${offense(id, rule)}?`,
190
+ instructions: {
191
+ question: `Which line of \`code\` most clearly ${offense(rule)}?`,
192
+ ...ruleFields(rule),
193
+ },
144
194
  criteria:
145
195
  block === undefined
146
196
  ? lineCriteria(lines, 1, lines.length)
@@ -159,15 +209,74 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
159
209
  }
160
210
  return {
161
211
  model,
162
- state: stateOf(lines, rules),
163
- questions: Record.map(rules, (rule, id) => ({
212
+ state: stateOf(lines),
213
+ questions: Record.map(rules, (rule) => ({
164
214
  type: "choice" as const,
165
- instructions: `Which block of state.code contains the line that most clearly ${offense(id, rule)}?`,
215
+ instructions: {
216
+ question: `Which block of \`code\` contains the line that most clearly ${offense(rule)}?`,
217
+ ...ruleFields(rule),
218
+ },
166
219
  criteria,
167
220
  })),
168
221
  };
169
222
  };
170
223
 
224
+ /**
225
+ * Whether Jev can take every request a file may need: its lines within two
226
+ * line choices, and its code beside the longest question any rule asks.
227
+ */
228
+ export const fits = (lines: Lines, rules: Rules): boolean => {
229
+ if (lines.length > MAX_LINES) return false;
230
+ const located = needsBlocks(lines) ? blockBody("", lines, rules) : locateBody("", lines, rules);
231
+ const questions = [
232
+ ...Object.values(judgeBody("", lines, rules).questions),
233
+ ...Object.values(located.questions),
234
+ ];
235
+ const longest = Math.max(0, ...questions.map(tokensOf));
236
+ return tokensOf(stateOf(lines)) + longest <= QUESTION_CONTEXT - WRAPPER;
237
+ };
238
+
239
+ export interface Body {
240
+ readonly model: string;
241
+ readonly state: unknown;
242
+ readonly questions: Readonly<Record<string, unknown>>;
243
+ }
244
+
245
+ /**
246
+ * A body as requests Jev can take: its questions in order, with a new
247
+ * request whenever the next question would not fit beside the state and
248
+ * the questions before it.
249
+ */
250
+ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
251
+ const room = REQUEST_CONTEXT - WRAPPER - tokensOf(body.state);
252
+ const requests: Array<{ readonly questions: Record<string, unknown>; tokens: number }> = [];
253
+ for (const [id, question] of Object.entries(body.questions)) {
254
+ const tokens = tokensOf(question);
255
+ const last = requests.at(-1);
256
+ if (last !== undefined && last.tokens + tokens <= room) {
257
+ last.questions[id] = question;
258
+ last.tokens += tokens;
259
+ } else {
260
+ requests.push({ questions: { [id]: question }, tokens });
261
+ }
262
+ }
263
+ return requests.map(({ questions }) => ({ ...body, questions }));
264
+ };
265
+
266
+ /** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
267
+ export const judgeRequests = (lines: Lines, rules: Rules): number =>
268
+ requestsOf(judgeBody("", lines, rules)).length;
269
+
270
+ /**
271
+ * The requests locating `rules` in a file takes, split to fit as `locate`
272
+ * sends them: a block choice first for a long file, then a line choice. For a
273
+ * long file the line choice is counted before a block is chosen, so it can
274
+ * run high.
275
+ */
276
+ export const locateRequests = (lines: Lines, rules: Rules): number =>
277
+ (needsBlocks(lines) ? requestsOf(blockBody("", lines, rules)).length : 0) +
278
+ requestsOf(locateBody("", lines, rules)).length;
279
+
171
280
  /** A choice keeps one criterion for "none", which leaves this many for partners. */
172
281
  const PARTNERS_PER_QUESTION = CHOICE_LIMIT - 1;
173
282
 
@@ -2,7 +2,15 @@ import type { Rule, RuleId } from "#config.ts";
2
2
  import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
3
3
  import { AdhereConfig } from "#services/AdhereConfig.ts";
4
4
  import { AuditCache, type Judgment, sha256 } from "#services/AuditCache.ts";
5
- import { Jev, type JevUnavailable, MAX_LINES, snippetOf } from "#services/Jev.ts";
5
+ import {
6
+ fits,
7
+ Jev,
8
+ JevUnavailable,
9
+ judgeQuestion,
10
+ judgeRequests,
11
+ locateRequests,
12
+ snippetOf,
13
+ } from "#services/Jev.ts";
6
14
  import { SourceWalker } from "#services/SourceWalker.ts";
7
15
  import { applicableRules } from "#rules.ts";
8
16
  import { type Crypto, Effect, Record } from "effect";
@@ -45,47 +53,94 @@ const fileResult = (
45
53
  const isEmpty = Record.isEmptyReadonlyRecord;
46
54
 
47
55
  /**
48
- * What a judgment depends on besides the file. A rule without code to avoid
49
- * hashes what every rule hashed before `avoid` existed, so its cached
50
- * judgments survive the upgrade.
56
+ * What a judgment depends on besides the file: the model, and the question
57
+ * asked for the rule, which carries the rule. An edited rule re-judges
58
+ * itself, and a question asked differently re-judges every rule.
51
59
  */
52
60
  const fingerprintOf = (model: string, rule: Rule) =>
53
- sha256(
54
- model +
55
- rule.description +
56
- (rule.reference ?? "") +
57
- (rule.avoid === undefined ? "" : `\u0000avoid\u0000${rule.avoid}`),
58
- );
61
+ sha256(`${model}\u0000${JSON.stringify(judgeQuestion(rule))}`);
59
62
 
60
- export const runAudit: Effect.Effect<
61
- AuditResult,
62
- WalkUnavailable | JevUnavailable,
63
- AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
63
+ /** One file before anything is sent: its rules, and which of them the cache answers. */
64
+ export interface FilePlan {
65
+ readonly file: ScannedFile;
66
+ /** Too long for Jev: counted, not judged. */
67
+ readonly skipped: boolean;
68
+ readonly hash: string;
69
+ readonly prepared: Readonly<Record<RuleId, PreparedRule>>;
70
+ /** Cached judgments still valid for the file's content and the rule's text. */
71
+ readonly kept: Readonly<Record<RuleId, Judgment>>;
72
+ readonly pending: Readonly<Record<RuleId, PreparedRule>>;
73
+ }
74
+
75
+ /** A run worked out from the files, the rules, and the cache, before any request. */
76
+ export interface AuditPlan {
77
+ readonly files: ReadonlyArray<FilePlan>;
78
+ /** Rules that apply to at least one file that is judged. */
79
+ readonly rules: number;
80
+ /** Every pair of a judged file and a rule that applies to it. */
81
+ readonly checks: number;
82
+ /** Checks a cached judgment answers. */
83
+ readonly cached: number;
84
+ /** Files too long to judge. */
85
+ readonly skipped: number;
86
+ /** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
87
+ readonly requests: number;
88
+ }
89
+
90
+ /** A file finished, for a progress counter: the requests it took and the findings it has. */
91
+ export interface FileDone {
92
+ readonly requests: number;
93
+ readonly findings: number;
94
+ }
95
+
96
+ const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(some, (k) => k.rule);
97
+
98
+ const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
99
+
100
+ /** A refusal that names the file it stopped at. Files finished before it are already cached. */
101
+ const inFile =
102
+ (file: ScannedFile) =>
103
+ <A, E, R>(self: Effect.Effect<A, E, R>) =>
104
+ Effect.mapError(self, (problem) =>
105
+ problem instanceof JevUnavailable
106
+ ? JevUnavailable.make({
107
+ message: `${file.path}: ${problem.message}. Files judged before it are cached, so a rerun continues from there.`,
108
+ })
109
+ : problem,
110
+ );
111
+
112
+ export const planAudit: Effect.Effect<
113
+ AuditPlan,
114
+ WalkUnavailable,
115
+ AdhereConfig | AuditCache | Crypto.Crypto | SourceWalker
64
116
  > = Effect.gen(function* () {
65
117
  const config = yield* AdhereConfig;
66
- const jev = yield* Jev;
67
118
  const cache = yield* AuditCache;
68
119
  const files = yield* (yield* SourceWalker).files;
69
120
 
70
121
  const configuredRules = (file: string) =>
71
- config.scopedRules === undefined
72
- ? config.rules
73
- : applicableRules(file, config.scopedRules);
74
-
75
- const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(some, (k) => k.rule);
122
+ config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
76
123
 
77
- const auditFile = Effect.fn("audit.file")(function* (file: ScannedFile) {
78
- if (file.lines.length > MAX_LINES) return fileResult("skipped", []);
124
+ const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
125
+ const rules = configuredRules(file.path);
126
+ // Too long for Jev's context: skipped and counted, rather than refused mid-run.
127
+ if (!fits(file.lines, rules)) {
128
+ return {
129
+ file,
130
+ skipped: true,
131
+ hash: "",
132
+ prepared: {},
133
+ kept: {},
134
+ pending: {},
135
+ } satisfies FilePlan;
136
+ }
79
137
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
80
- Object.entries(configuredRules(file.path)),
138
+ Object.entries(rules),
81
139
  ([id, rule]) =>
82
140
  Effect.map(
83
141
  fingerprintOf(config.model, rule),
84
142
  (fingerprint) =>
85
- [
86
- id,
87
- { rule, fingerprint, threshold: rule.threshold ?? config.threshold },
88
- ] as const,
143
+ [id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
89
144
  ),
90
145
  ).pipe(Effect.map(Record.fromEntries));
91
146
  const hash = yield* sha256(file.lines.join("\n"));
@@ -96,64 +151,140 @@ export const runAudit: Effect.Effect<
96
151
  (judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
97
152
  );
98
153
  const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
154
+ return { file, skipped: false, hash, prepared, kept, pending } satisfies FilePlan;
155
+ });
99
156
 
100
- const probabilities = isEmpty(pending) ? {} : yield* jev.judge(file.lines, rulesOf(pending));
101
- const judged: Record<RuleId, Judgment> = { ...kept };
102
- for (const [id, probability] of Object.entries(probabilities)) {
103
- const k = pending[id];
104
- if (k !== undefined) judged[id] = { fingerprint: k.fingerprint, probability };
105
- }
157
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
158
+ const planned: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
159
+ concurrency: 8,
160
+ });
161
+ const judged = planned.filter((plan) => !plan.skipped);
162
+ const total = (count: (plan: FilePlan) => number) =>
163
+ judged.reduce((sum, plan) => sum + count(plan), 0);
164
+ return {
165
+ files: planned,
166
+ rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
167
+ checks: total((plan) => sizeOf(plan.prepared)),
168
+ cached: total((plan) => sizeOf(plan.kept)),
169
+ skipped: planned.length - judged.length,
170
+ requests: total((plan) =>
171
+ isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
172
+ ),
173
+ };
174
+ });
175
+
176
+ /**
177
+ * Judges what the plan left pending and locates the findings, a file at a
178
+ * time, telling `progress` as each file finishes.
179
+ */
180
+ export const executeAudit = (
181
+ plan: AuditPlan,
182
+ progress: (done: FileDone) => Effect.Effect<void> = () => Effect.void,
183
+ ): Effect.Effect<AuditResult, JevUnavailable, AuditCache | Jev> =>
184
+ Effect.gen(function* () {
185
+ const jev = yield* Jev;
186
+ const cache = yield* AuditCache;
187
+
188
+ /** Judges and locates one file the plan left pending, with the requests that took. */
189
+ const judgeFile = Effect.fn("audit.judgeFile")(function* ({
190
+ file,
191
+ hash,
192
+ prepared,
193
+ kept,
194
+ pending,
195
+ }: FilePlan) {
196
+ const probabilities = isEmpty(pending)
197
+ ? {}
198
+ : yield* jev.judge(file.lines, rulesOf(pending)).pipe(inFile(file));
199
+ const judged: Record<RuleId, Judgment> = { ...kept };
200
+ for (const [id, probability] of Object.entries(probabilities)) {
201
+ const k = pending[id];
202
+ if (k !== undefined) judged[id] = { fingerprint: k.fingerprint, probability };
203
+ }
204
+
205
+ const flagged = Record.filter(prepared, (k, id) => {
206
+ const judgment = judged[id];
207
+ return (
208
+ judgment !== undefined &&
209
+ judgment.probability > k.threshold &&
210
+ judgment.line === undefined
211
+ );
212
+ });
213
+ const lines = isEmpty(flagged)
214
+ ? {}
215
+ : yield* jev.locate(file.lines, rulesOf(flagged)).pipe(inFile(file));
216
+ const located = Record.map(judged, (judgment, id) => {
217
+ const line = lines[id];
218
+ return line === undefined
219
+ ? judgment
220
+ : { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
221
+ });
106
222
 
107
- const flagged = Record.filter(prepared, (k, id) => {
108
- const judgment = judged[id];
109
- return (
110
- judgment !== undefined && judgment.probability > k.threshold && judgment.line === undefined
111
- );
223
+ const cached = isEmpty(pending) && isEmpty(flagged);
224
+ if (!cached) yield* cache.put(file.path, { hash, judgments: located });
225
+
226
+ const findings = Object.entries(located)
227
+ .flatMap(([id, judgment]): ReadonlyArray<Finding> => {
228
+ const k = prepared[id];
229
+ return k !== undefined &&
230
+ judgment.line !== undefined &&
231
+ judgment.probability > k.threshold
232
+ ? [
233
+ {
234
+ rule: id,
235
+ description: k.rule.description,
236
+ reference: k.rule.reference,
237
+ avoid: k.rule.avoid,
238
+ file: file.path,
239
+ line: judgment.line,
240
+ snippet: judgment.snippet ?? "",
241
+ probability: judgment.probability,
242
+ },
243
+ ]
244
+ : [];
245
+ })
246
+ .sort((a, b) => a.line - b.line);
247
+ return {
248
+ result: fileResult(cached ? "cached" : "judged", findings),
249
+ requests:
250
+ (isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
251
+ (isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
252
+ };
112
253
  });
113
- const lines = isEmpty(flagged) ? {} : yield* jev.locate(file.lines, rulesOf(flagged));
114
- const located = Record.map(judged, (judgment, id) => {
115
- const line = lines[id];
116
- return line === undefined
117
- ? judgment
118
- : { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
254
+
255
+ const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
256
+ const { result, requests } = plan.skipped
257
+ ? { result: fileResult("skipped", []), requests: 0 }
258
+ : yield* judgeFile(plan).pipe(
259
+ // Jev counted more than `fits` estimated. Its count decides: skipped the same way.
260
+ Effect.catchTag("JevOverflow", () =>
261
+ Effect.succeed({
262
+ result: fileResult("skipped", []),
263
+ requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
264
+ }),
265
+ ),
266
+ );
267
+ yield* progress({ requests, findings: result.findings.length });
268
+ return result;
119
269
  });
120
270
 
121
- const cached = isEmpty(pending) && isEmpty(flagged);
122
- if (!cached) yield* cache.put(file.path, { hash, judgments: located });
123
-
124
- const findings = Object.entries(located)
125
- .flatMap(([id, judgment]): ReadonlyArray<Finding> => {
126
- const k = prepared[id];
127
- return k !== undefined && judgment.line !== undefined && judgment.probability > k.threshold
128
- ? [
129
- {
130
- rule: id,
131
- description: k.rule.description,
132
- reference: k.rule.reference,
133
- avoid: k.rule.avoid,
134
- file: file.path,
135
- line: judgment.line,
136
- snippet: judgment.snippet ?? "",
137
- probability: judgment.probability,
138
- },
139
- ]
140
- : [];
141
- })
142
- .sort((a, b) => a.line - b.line);
143
- return fileResult(cached ? "cached" : "judged", findings);
271
+ const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
272
+ const count = (status: FileResult["status"]) =>
273
+ results.filter((result) => result.status === status).length;
274
+ return {
275
+ files: plan.files.length,
276
+ judged: count("judged"),
277
+ cached: count("cached"),
278
+ skipped: count("skipped"),
279
+ findings: results.flatMap((result) => result.findings),
280
+ };
144
281
  });
145
282
 
146
- const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
147
- const results = yield* Effect.forEach(sorted, auditFile, { concurrency: 8 });
148
- const count = (status: FileResult["status"]) =>
149
- results.filter((result) => result.status === status).length;
150
- return {
151
- files: sorted.length,
152
- judged: count("judged"),
153
- cached: count("cached"),
154
- skipped: count("skipped"),
155
- findings: results.flatMap((result) => result.findings),
156
- };
157
- });
283
+ /** Plans the run and executes it, without asking and without a counter. */
284
+ export const runAudit: Effect.Effect<
285
+ AuditResult,
286
+ WalkUnavailable | JevUnavailable,
287
+ AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
288
+ > = Effect.flatMap(planAudit, (plan) => executeAudit(plan));
158
289
 
159
290
  export { type RenderOptions, render } from "#workflows/format.ts";
@@ -1,5 +1,5 @@
1
1
  import { type Kind, tokenize } from "#highlight.ts";
2
- import type { AuditResult, Finding } from "#workflows/audit.ts";
2
+ import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit.ts";
3
3
 
4
4
  /**
5
5
  * The report as data: lines of spans, each span a part of the frame or a
@@ -143,3 +143,44 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
143
143
  ...result.findings.flatMap((finding) => [...frame(finding, options.root), []]),
144
144
  ...summary(result),
145
145
  ].map((line) => serialize(line, options.color === true));
146
+
147
+ /**
148
+ * What a run will do, before it asks: the files and rules found, the checks
149
+ * between them and how many the cache answers, then what judging the rest
150
+ * takes.
151
+ */
152
+ export const describePlan = (plan: AuditPlan): ReadonlyArray<string> => {
153
+ const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
154
+ const skipped =
155
+ plan.skipped > 0 ? `, ${counted(plan.skipped, "file", "files")} too long to judge` : "";
156
+ const found = `${counted(plan.files.length, "file", "files")} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
157
+ if (plan.requests === 0) {
158
+ return [found, plan.checks === 0 ? "Nothing to judge." : "The cache answers every check."];
159
+ }
160
+ const rest = plan.cached > 0 ? `the other ${plan.checks - plan.cached}` : "them";
161
+ return [
162
+ found,
163
+ `Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.`,
164
+ ];
165
+ };
166
+
167
+ /** The question before a run sends anything. */
168
+ export const sendQuestion = (plan: AuditPlan): string =>
169
+ `Send ${counted(plan.requests, "request", "requests")} to Jev?`;
170
+
171
+ /** A run's progress so far: every file it finished, with the requests and findings they added up to. */
172
+ export interface Progress {
173
+ readonly files: number;
174
+ readonly requests: number;
175
+ readonly findings: number;
176
+ }
177
+
178
+ export const progressAfter = (progress: Progress, done: FileDone): Progress => ({
179
+ files: progress.files + 1,
180
+ requests: progress.requests + done.requests,
181
+ findings: progress.findings + done.findings,
182
+ });
183
+
184
+ /** The counter a run redraws as files finish. */
185
+ export const progressLine = (progress: Progress, plan: AuditPlan): string =>
186
+ `${progress.files}/${plan.files.length} files, ${counted(progress.requests, "request", "requests")} sent, ${counted(progress.findings, "finding", "findings")}`;