@drkmttr/adhere 0.9.3 → 0.9.5

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
@@ -66,7 +66,10 @@ Found 1 error.
66
66
  42 files, 3 judged, 39 cached.
67
67
  ```
68
68
 
69
- The header is the rule id, Jev's probability, and the rule's description.
69
+ The header is the rule id, Jev's probability, and the rule's description. A
70
+ preset's rule has the preset's name first, as in
71
+ `effect/basics/external-calls-are-resilient`, so a report that mixes presets
72
+ with a repo's own rules says where each came from.
70
73
  Under it is the line Jev points at, underlined, with the code around it: the
71
74
  largest statement around the line that is 30 lines or fewer, usually the whole
72
75
  function, and up to 3 lines of whole statements on either side. The
@@ -101,15 +104,27 @@ prints a completion script.
101
104
 
102
105
  ### Before and during a run
103
106
 
104
- Before it sends anything, `lint` says on stderr what it found and what judging
105
- takes:
107
+ Until it knows what judging takes, a status line on stderr says what `lint` is
108
+ doing: looking for `.adhere/` rules, listing and reading the files, then
109
+ planning, each with a count, such as `Planning: 5,120 of 10,333 files`. The
110
+ line is drawn only on a terminal, and not at `--log-level debug` or below,
111
+ whose log lines would land in it. Then `lint` says on stderr what it found and
112
+ what judging takes:
106
113
 
107
114
  ```text
108
115
  200 files and 14 rules: 2800 checks, 1400 cached.
109
116
  Judging the other 1400 takes 200 requests to Jev, plus 1 or more for each file with a finding.
110
- ? Send 200 requests to Jev? › (Y/n)
117
+ Those carry about 1.9 million input tokens: about $0.08 at $0.042 per million, and more for locating findings.
118
+ ? Send 200 requests to Jev, about $0.08? › (Y/n)
111
119
  ```
112
120
 
121
+ The cost is adhere's estimate of the input tokens times the model's price.
122
+ Jev charges only for input tokens: $0.042 a million for `jev-latest`, per
123
+ [TypeSafe AI's models page](https://docs.typesafe.ai/models) in September 2026.
124
+ The estimate runs about a tenth high, and locating findings, which depends on
125
+ what judging finds, comes on top. For a model adhere has no price for, the plan
126
+ gives the tokens alone.
127
+
113
128
  It asks only with a terminal on stdin and stdout, and never when the cache
114
129
  answers every check; `--yes` sends without asking. While it runs, a counter on
115
130
  stderr shows the files done, the requests sent, and the findings so far, and
@@ -370,8 +385,11 @@ each rule worded otherwise.
370
385
  ### Presets
371
386
 
372
387
  A preset has the shape of a config without `presets`: `rules`, and optionally
373
- `model` and `threshold`. Name one in the config, or on the command line with
374
- `--preset`, in which case the config file is optional. There are two:
388
+ `model` and `threshold`. Name presets in the config's `presets`, or on the
389
+ command line with `--preset`, repeated or separated by commas, as in
390
+ `--preset effect,alchemy`, in which case the config file is optional. Presets
391
+ named in both places apply together. Reports name a preset's rule with the
392
+ preset first, as in `alchemy/secrets/…`. There are two:
375
393
 
376
394
  - `effect`: Markdown rules in [`presets/effect/`](./presets/effect/), lifted
377
395
  from the [effect-solutions](https://github.com/kitlangton/effect-solutions)
@@ -489,8 +507,11 @@ A cache file is written once and never changed: it is also named for the hash
489
507
  of its own text. Two branches that judge the same code add files rather than
490
508
  edit them, so git merges the cache without a conflict. After a run that reads
491
509
  every file, adhere prunes the cache: it deletes answers about content no file
492
- has and answers to rule texts no rule asks, and folds what is left into one
493
- file per content. A run narrowed by `--filter` does not prune. To keep the
510
+ has, and folds what is left into one file per content. Answers to rules the
511
+ run left out stay, so a run with other presets or rules, or with one topic,
512
+ loses nothing another run still asks; answers to a rule's old texts stay too,
513
+ until the content they are about is gone. A run narrowed by `--filter` does not
514
+ prune. To keep the
494
515
  cache out of diffs, mark it as generated in `.gitattributes`:
495
516
 
496
517
  ```text
package/bin/adhere.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { existsSync } from "node:fs";
10
10
  import { createRequire } from "node:module";
11
+ import { constants } from "node:os";
11
12
  import { fileURLToPath } from "node:url";
12
13
 
13
14
  const platform = `${process.platform}-${process.arch}`;
@@ -26,6 +27,14 @@ const resolveExecutable = () => {
26
27
  const run = (command, commandArgs) => {
27
28
  const result = spawnSync(command, commandArgs, { stdio: "inherit" });
28
29
  if (result.error) throw result.error;
30
+ if (result.signal) {
31
+ // A killed process says nothing itself, so say what killed it.
32
+ const memory =
33
+ result.signal === "SIGKILL" ? ", usually the system ending it for running out of memory" : "";
34
+ console.error(`adhere: the executable was killed by ${result.signal}${memory}.`);
35
+ process.exitCode = 128 + (constants.signals[result.signal] ?? 0);
36
+ return;
37
+ }
29
38
  process.exitCode = result.status ?? 1;
30
39
  };
31
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "private": false,
5
5
  "description": "Lints a repository against the rules in .adhere/, judged by Jev.",
6
6
  "license": "MIT",
@@ -68,10 +68,10 @@
68
68
  }
69
69
  },
70
70
  "optionalDependencies": {
71
- "@drkmttr/adhere-darwin-arm64": "0.9.3",
72
- "@drkmttr/adhere-darwin-x64": "0.9.3",
73
- "@drkmttr/adhere-linux-arm64": "0.9.3",
74
- "@drkmttr/adhere-linux-x64": "0.9.3",
75
- "@drkmttr/adhere-win32-x64": "0.9.3"
71
+ "@drkmttr/adhere-darwin-arm64": "0.9.5",
72
+ "@drkmttr/adhere-darwin-x64": "0.9.5",
73
+ "@drkmttr/adhere-linux-arm64": "0.9.5",
74
+ "@drkmttr/adhere-linux-x64": "0.9.5",
75
+ "@drkmttr/adhere-win32-x64": "0.9.5"
76
76
  }
77
77
  }
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ 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 { Status } from "#services/Status.ts";
6
7
  import {
7
8
  type AuditPlan,
8
9
  executeAudit,
@@ -61,10 +62,31 @@ class ContradictionsReported extends Schema.TaggedError<ContradictionsReported>(
61
62
  readonly [Runtime.errorReported] = false;
62
63
  }
63
64
 
64
- const preset = Flag.choice("preset", presetNames).pipe(
65
- Flag.optional,
65
+ /** The names each `--preset` gives, split at commas: `--preset effect,alchemy` gives two. */
66
+ const presetsIn = (values: ReadonlyArray<string>): ReadonlyArray<string> =>
67
+ values
68
+ .flatMap((value) => value.split(","))
69
+ .map((name) => name.trim())
70
+ .filter((name) => name.length > 0);
71
+
72
+ const isPresetName = (name: string): name is PresetName =>
73
+ (presetNames as ReadonlyArray<string>).includes(name);
74
+
75
+ const preset = Flag.string("preset").pipe(
76
+ Flag.atLeast(0),
77
+ Flag.filterMap(
78
+ (values): Option.Option<ReadonlyArray<PresetName>> => {
79
+ const names = presetsIn(values);
80
+ return names.every(isPresetName) ? Option.some([...new Set(names)]) : Option.none();
81
+ },
82
+ // The CLI puts this after "Expected:".
83
+ (values) => {
84
+ const unknown = presetsIn(values).filter((name) => !isPresetName(name));
85
+ return `one of ${presetNames.join(", ")}, or several separated by commas (not ${unknown.join(", ")})`;
86
+ },
87
+ ),
66
88
  Flag.withDescription(
67
- "Add a built-in rule set, or one of its topics, such as effect/basics. With a preset, .adhere/config.ts is optional.",
89
+ `Add built-in rule sets, or their topics, such as effect/basics. Repeat the flag or separate names with commas, as in --preset effect,alchemy. With a preset, .adhere/config.ts is optional. (choices: ${presetNames.join(", ")})`,
68
90
  ),
69
91
  );
70
92
 
@@ -141,20 +163,51 @@ const counterFor = (plan: AuditPlan, logging: LogLevel.LogLevel) => {
141
163
  };
142
164
  };
143
165
 
166
+ /**
167
+ * The status line for what a run does before its plan: walking the checkout,
168
+ * reading files, planning. On a terminal it is one line of stderr, redrawn at
169
+ * most every 100 ms and cut to the terminal's width; anywhere else, and at
170
+ * debug or below, whose log lines would land in it, it shows nothing.
171
+ */
172
+ const statusLayer = Layer.effect(Status)(
173
+ Effect.gen(function* () {
174
+ const logging = yield* References.MinimumLogLevel;
175
+ if (process.stderr.isTTY !== true || !LogLevel.isGreaterThan(logging, "Debug")) {
176
+ return Status.defaultValue();
177
+ }
178
+ let drawn = 0;
179
+ let shown = false;
180
+ return {
181
+ show: (text: string) =>
182
+ Effect.sync(() => {
183
+ const now = Date.now();
184
+ if (now - drawn < 100) return;
185
+ drawn = now;
186
+ shown = true;
187
+ // A terminal that reports no width, as some report 0, gets 80 columns.
188
+ const width = (process.stderr.columns || 80) - 1;
189
+ process.stderr.write(`\r\u001b[2K${text.slice(0, width)}`);
190
+ }),
191
+ clear: Effect.sync(() => {
192
+ if (shown) process.stderr.write("\r\u001b[2K");
193
+ shown = false;
194
+ drawn = 0;
195
+ }),
196
+ };
197
+ }),
198
+ );
199
+
144
200
  const force = Flag.boolean("force").pipe(
145
201
  Flag.withDefault(false),
146
202
  Flag.withDescription("Overwrite existing scaffold files."),
147
203
  );
148
204
 
149
- const chosenPresets = (flag: Option.Option<PresetName>): ReadonlyArray<PresetName> =>
150
- Option.isSome(flag) ? [flag.value] : [];
151
-
152
205
  export const auditLayer = (overrides: Overrides, filter: ReadonlyArray<string> = []) =>
153
206
  Layer.mergeAll(
154
207
  SourceWalkerLive(filter),
155
208
  AuditCacheLive,
156
209
  JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
157
- ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
210
+ ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)), Layer.provideMerge(statusLayer));
158
211
 
159
212
  /** `adhere lint`: the audit. */
160
213
  export const lintCommand = Command.make(
@@ -200,7 +253,7 @@ export const lintCommand = Command.make(
200
253
  Command.provide((input) =>
201
254
  auditLayer(
202
255
  {
203
- presets: chosenPresets(input.preset),
256
+ presets: input.preset,
204
257
  threshold: Option.getOrUndefined(input.threshold),
205
258
  rpm: Option.getOrUndefined(input.rpm),
206
259
  },
@@ -214,7 +267,7 @@ export const validateLayer = (overrides: Overrides) =>
214
267
  Layer.merge(
215
268
  JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
216
269
  AuditCacheLive,
217
- ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
270
+ ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)), Layer.provideMerge(statusLayer));
218
271
 
219
272
  /**
220
273
  * `adhere validate`: everything `lint` loads, then how each rule's wording
@@ -250,7 +303,7 @@ export const validateCommand = Command.make("validate", { preset }, () =>
250
303
  Command.withDescription(
251
304
  "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.",
252
305
  ),
253
- Command.provide((input) => validateLayer({ presets: chosenPresets(input.preset) })),
306
+ Command.provide((input) => validateLayer({ presets: input.preset })),
254
307
  );
255
308
 
256
309
  /** `adhere init`: the scaffold. */
@@ -1,4 +1,4 @@
1
- import type { RuleEntry } from "#rules.ts";
1
+ import { type RuleEntry, shownId } from "#rules.ts";
2
2
  import { Jev, type Pair } from "#services/Jev.ts";
3
3
  import { Effect } from "effect";
4
4
 
@@ -69,7 +69,9 @@ export const findContradictions = Effect.fn("findContradictions")(function* (
69
69
  });
70
70
  });
71
71
 
72
- const locationOf = (entry: RuleEntry): string => `${entry.id} (${entry.file ?? entry.scope})`;
72
+ /** Where a rule lives: a preset's names the preset in its id; the project's own rule names its file. */
73
+ const locationOf = (entry: RuleEntry): string =>
74
+ entry.preset === undefined ? `${entry.id} (${entry.file ?? entry.scope})` : shownId(entry);
73
75
 
74
76
  export const formatContradictions = (contradictions: ReadonlyArray<Contradiction>): string => {
75
77
  if (contradictions.length === 0) {
package/src/mechanical.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Rule } from "#config.ts";
2
- import type { RuleEntry } from "#rules.ts";
2
+ import { type RuleEntry, shownId } from "#rules.ts";
3
3
  import { AuditCache, sha256, type Tally } from "#services/AuditCache.ts";
4
4
  import { linterQuestion } from "#services/Jev.ts";
5
5
  import { Effect } from "effect";
@@ -76,7 +76,7 @@ export const formatLinterCheck = (
76
76
  : [
77
77
  `${flagged.length} ${flagged.length === 1 ? "rule" : "rules"} flagged by the linter check:`,
78
78
  ...flagged.flatMap((one) => [
79
- ` ${one.entry.id}${one.entry.file === undefined ? "" : ` (${displayPath(one.entry.file, root)})`}`,
79
+ ` ${shownId(one.entry)}${one.entry.file === undefined ? "" : ` (${displayPath(one.entry.file, root)})`}`,
80
80
  ` ${adviceOf(one)}`,
81
81
  ]),
82
82
  ]),
package/src/presets.ts CHANGED
@@ -57,4 +57,7 @@ export const presets: Readonly<Record<WholePreset, BuiltInPreset>> = {
57
57
  };
58
58
 
59
59
  /** The directory each whole preset reads, for checking `topics` against it. */
60
+ /** The whole preset a name belongs to: effect, for effect and for effect/basics alike. */
61
+ export const wholePresetOf = (name: PresetName): string => name.split("/")[0] ?? name;
62
+
60
63
  export const topicsOf = (preset: WholePreset): ReadonlyArray<string> => topics[preset];
package/src/pricing.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Jev's price for input tokens, in dollars per million, by the name a config
3
+ * sends as its model. Output tokens are free. From TypeSafe AI's models page,
4
+ * https://docs.typesafe.ai/models, on 2026-09-25, when jev-latest and
5
+ * jev-preview both pointed to jev-1.13.0.
6
+ */
7
+ const DOLLARS_PER_MILLION: Readonly<Record<string, number>> = {
8
+ "jev-1.13.0": 0.042,
9
+ "jev-latest": 0.042,
10
+ "jev-preview": 0.042,
11
+ };
12
+
13
+ /** The model's price per million input tokens, when adhere knows it. */
14
+ export const priceOf = (model: string): number | undefined => DOLLARS_PER_MILLION[model];
package/src/rules.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  SKIPPED_DIRECTORIES,
7
7
  } from "#config.ts";
8
8
  import { parseRuleMarkdown } from "#markdown.ts";
9
+ import { walkFiles } from "#walk.ts";
10
+ import { clearingStatus, countOf, Status } from "#services/Status.ts";
9
11
  import { Effect, FileSystem, Path, Record } from "effect";
10
12
 
11
13
  const refused = (directory: string) => (problem: { readonly message: string }) =>
@@ -18,10 +20,19 @@ export interface RuleEntry {
18
20
  readonly rule: Rule;
19
21
  readonly scope: string;
20
22
  readonly file?: string;
21
- /** The built-in rule set the rule came from, when it is not the project's own. */
23
+ /** The built-in preset the rule came from, by its whole name (effect for effect/basics), when it is not the project's own. */
22
24
  readonly preset?: string;
23
25
  }
24
26
 
27
+ /**
28
+ * A rule's id as output shows it: a preset's rule with the preset's name
29
+ * first, as in effect/basics/external-calls-are-resilient, so a report that
30
+ * mixes presets with the project's own rules says where each came from. The
31
+ * project's own rules show their `.adhere/` ids as they are.
32
+ */
33
+ export const shownId = (entry: { readonly id: RuleId; readonly preset?: string }): string =>
34
+ entry.preset === undefined ? entry.id : `${entry.preset}/${entry.id}`;
35
+
25
36
  export type RuleSet = ReadonlyArray<RuleEntry>;
26
37
 
27
38
  const ADHERE_SEGMENT = ".adhere";
@@ -36,6 +47,21 @@ const segmentsOf = (relative: string): ReadonlyArray<string> =>
36
47
 
37
48
  const isMarkdown = (path: string): boolean => path.endsWith(".md");
38
49
 
50
+ /**
51
+ * Whether the rule walk reads into a directory: on the way to a `.adhere/`,
52
+ * not a skipped tree or `.git`; inside one, anything but its cache, since a
53
+ * directory there such as `.adhere/e2e/` is a rule topic.
54
+ */
55
+ const entersForRules = (relative: string): boolean => {
56
+ const segments = segmentsOf(relative);
57
+ const name = segments.at(-1) ?? "";
58
+ const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
59
+ if (adhere >= 0 && adhere < segments.length - 1) {
60
+ return !(adhere === segments.length - 2 && name === CACHE_SEGMENT);
61
+ }
62
+ return name === ADHERE_SEGMENT || !(SKIPPED_DIRECTORIES.has(name) || name === ".git");
63
+ };
64
+
39
65
  const isNestedRuleFile = (relative: string): boolean => {
40
66
  const segments = segmentsOf(relative);
41
67
  const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
@@ -108,10 +134,15 @@ export const loadRules = Effect.fn("loadRules")(function* (directory: string) {
108
134
  export const loadAdhereRuleSet = Effect.fn("loadAdhereRuleSet")(function* (root: string) {
109
135
  const fs = yield* FileSystem.FileSystem;
110
136
  const path = yield* Path.Path;
111
- const entries = yield* fs
112
- .readDirectory(root, { recursive: true })
113
- .pipe(Effect.mapError(refused(root)));
114
- const files = entries.filter(isNestedRuleFile).sort();
137
+ const status = yield* Status;
138
+ const entries = yield* clearingStatus(
139
+ walkFiles(fs, path, root, entersForRules, (directories) =>
140
+ status.show(
141
+ `Looking for .adhere/ rules: ${countOf(directories, "directory", "directories")} read`,
142
+ ),
143
+ ),
144
+ ).pipe(Effect.mapError(refused(root)));
145
+ const files = entries.filter(isNestedRuleFile);
115
146
  return yield* Effect.forEach(files, (relative) =>
116
147
  Effect.gen(function* () {
117
148
  const file = joinPath(path, root, segmentsOf(relative));
@@ -11,7 +11,7 @@ import {
11
11
  type ResolvedConfig,
12
12
  resolveConfig,
13
13
  } from "#config.ts";
14
- import { type PresetName, presetOf } from "#presets.ts";
14
+ import { type PresetName, presetOf, wholePresetOf } from "#presets.ts";
15
15
  import {
16
16
  applicableRules,
17
17
  globalRuleSet,
@@ -95,7 +95,10 @@ export const AdhereConfigLive = (overrides: Overrides) =>
95
95
  ).pipe(Effect.map(Record.fromEntries));
96
96
  const resolved = resolveConfig({ ...config, rules }, overrides, registry);
97
97
  const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
98
- globalRuleSet(preset.rules, cwd).map((entry) => ({ ...entry, preset: name })),
98
+ globalRuleSet(preset.rules, cwd).map((entry) => ({
99
+ ...entry,
100
+ preset: wholePresetOf(name as PresetName),
101
+ })),
99
102
  );
100
103
  yield* Effect.logDebug(
101
104
  `config: ${configFile ?? "no config file"}; presets ${Object.keys(registry).join(", ") || "none"}; ${presetEntries.length} preset and ${projectEntries.length} project rules; model ${resolved.model}, threshold ${resolved.threshold}${resolved.rpm === undefined ? "" : `, at most ${resolved.rpm} requests a minute`}`,
@@ -45,13 +45,11 @@ export const Tally = Schema.Struct({
45
45
  export interface Tally extends Schema.Schema.Type<typeof Tally> {}
46
46
  const decodeTally = Schema.decodeUnknownOption(Schema.fromJsonString(Tally));
47
47
 
48
- /** What a run that read every file still needs from the cache. */
48
+ /** What a run that read every file tells the cache. */
49
49
  export interface Live {
50
- /** The hash of each file's content. */
50
+ /** The hash of each file's content, too long to judge or not. */
51
51
  readonly hashes: ReadonlySet<string>;
52
- /** The fingerprint of each rule text asked about a file. */
53
- readonly fingerprints: ReadonlySet<string>;
54
- /** The key of each rule's tally. */
52
+ /** The key of each of the run's rules' tallies, to carry over from the layout before content keys. */
55
53
  readonly tallies: ReadonlySet<string>;
56
54
  }
57
55
 
@@ -84,9 +82,11 @@ export class AuditCache extends Context.Service<
84
82
  readonly tally: (key: string) => Effect.Effect<Tally | undefined>;
85
83
  readonly putTally: (key: string, tally: Tally) => Effect.Effect<void>;
86
84
  /**
87
- * Deletes what `live` does not need, and folds the rest into one file per
88
- * content and per tally. Only after a run that read every file: it cannot
89
- * tell what the files it did not read need. Succeeds with the files deleted.
85
+ * Deletes the answers about content no file has, and folds the rest into
86
+ * one file per content and per tally. Answers to rules the run left out
87
+ * stay, as do their tallies: a run with other presets or rules asks them.
88
+ * Only after a run that read every file: it cannot tell what content the
89
+ * files it did not read have. Succeeds with the files deleted.
90
90
  */
91
91
  readonly prune: (live: Live) => Effect.Effect<number>;
92
92
  }
@@ -275,10 +275,7 @@ export const AuditCacheLive = Layer.effect(AuditCache)(
275
275
  // Folding replaces or removes the group being visited, which a Map's iteration allows.
276
276
  for (const group of (yield* indexes.files).keys()) {
277
277
  const entries = live.hashes.has(group) ? yield* readGroup("files", group, decodeEntry) : [];
278
- const answers = Record.filter(
279
- unionOf(entries.map((entry) => entry.answers)),
280
- (_, fingerprint) => live.fingerprints.has(fingerprint),
281
- );
278
+ const answers = unionOf(entries.map((entry) => entry.answers));
282
279
  deleted += yield* fold(
283
280
  "files",
284
281
  group,
@@ -286,7 +283,7 @@ export const AuditCacheLive = Layer.effect(AuditCache)(
286
283
  );
287
284
  }
288
285
  for (const key of (yield* indexes.tallies).keys()) {
289
- const tallies = live.tallies.has(key) ? yield* readGroup("tallies", key, decodeTally) : [];
286
+ const tallies = yield* readGroup("tallies", key, decodeTally);
290
287
  deleted += yield* fold(
291
288
  "tallies",
292
289
  key,
@@ -344,6 +344,23 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
344
344
  return requests.map(({ questions }) => ({ ...body, questions }));
345
345
  };
346
346
 
347
+ /**
348
+ * The requests judging `rules` over a file takes, and about how many input
349
+ * tokens they carry, by `tokensOf`: the judge body, split to fit, as `judge`
350
+ * sends it, each request with the file again.
351
+ */
352
+ export const judgeLoad = (
353
+ lines: Lines,
354
+ rules: Rules,
355
+ sampled: ReadonlyArray<RuleId> = [],
356
+ ): { readonly requests: number; readonly tokens: number } => {
357
+ const requests = requestsOf(judgeBody("", lines, rules, sampled));
358
+ return {
359
+ requests: requests.length,
360
+ tokens: requests.reduce((sum, request) => sum + tokensOf(request), 0),
361
+ };
362
+ };
363
+
347
364
  /** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
348
365
  export const judgeRequests = (
349
366
  lines: Lines,
@@ -3,6 +3,8 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect";
3
3
  import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
4
4
  import type { ScannedFile } from "#models/Audit.ts";
5
5
  import { WalkUnavailable } from "#models/Audit.ts";
6
+ import { walkFiles } from "#walk.ts";
7
+ import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
6
8
 
7
9
  /** Walks the repository's TypeScript source, skipping what is not ours to read. */
8
10
  export class SourceWalker extends Context.Service<
@@ -63,9 +65,15 @@ const isScannable = (file: string, self: string): boolean =>
63
65
  const refused = (problem: { readonly message: string }): WalkUnavailable =>
64
66
  WalkUnavailable.make({ message: problem.message });
65
67
 
66
- /** `readdir(recursive)` yields paths relative to the root it read: root them. */
68
+ /** The walk yields paths relative to the root it read: root them. */
67
69
  const rooted = (root: string, paths: ReadonlyArray<string>) => paths.map((rel) => `${root}/${rel}`);
68
70
 
71
+ /** Whether the walk reads into a directory: never a skipped tree, `.adhere/`, or `.git`. */
72
+ const entersForSource = (relative: string): boolean => {
73
+ const name = relative.split("/").at(-1) ?? "";
74
+ return !(SKIPPED_DIRECTORIES.has(name) || name === ADHERE_DIRECTORY || name === ".git");
75
+ };
76
+
69
77
  /** A test on a path relative to the root being read: whether the run wants it. */
70
78
  type Keep = (relative: string) => boolean;
71
79
 
@@ -76,20 +84,27 @@ const scannable = (root: string, paths: ReadonlyArray<string>, self: string, kee
76
84
  paths.filter((relative) => !isInSkippedTree(relative) && keep(relative)),
77
85
  ).filter((file) => isScannable(file, self));
78
86
 
79
- /** One root's paths, filtered to what the audit reads. */
80
- const listRoot = Effect.fn("SourceWalker.listRoot")(
81
- (fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
82
- Effect.flatMap(fs.readDirectory(root, { recursive: true }), (paths) => {
83
- const files = scannable(root, paths, self, keep);
84
- // The paths listed against the files kept: node_modules shows as the difference.
85
- return Effect.as(
86
- Effect.logDebug(
87
- `listed ${root}: ${paths.length} paths, ${files.length} source files to read`,
88
- ),
89
- files,
90
- );
91
- }),
92
- );
87
+ /** One root's files, filtered to what the audit reads. `label` names the root on the status line. */
88
+ const listRoot = Effect.fn("SourceWalker.listRoot")(function* (
89
+ fs: FileSystem.FileSystem,
90
+ path: Path.Path,
91
+ root: string,
92
+ label: string,
93
+ self: string,
94
+ keep: Keep,
95
+ ) {
96
+ const status = yield* Status;
97
+ const paths = yield* walkFiles(fs, path, root, entersForSource, (directories, found) =>
98
+ status.show(
99
+ `Listing ${label}: ${countOf(directories, "directory", "directories")}, ${countOf(found, "file", "files")}`,
100
+ ),
101
+ );
102
+ const files = scannable(root, paths, self, keep);
103
+ yield* Effect.logDebug(
104
+ `listed ${root}: ${paths.length} files, ${files.length} source files to read`,
105
+ );
106
+ return files;
107
+ });
93
108
 
94
109
  /** One scannable path, read into lines. */
95
110
  const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem, path: string) =>
@@ -99,14 +114,42 @@ const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem,
99
114
  })).pipe(Effect.tap((file) => Effect.logTrace(`read ${path}: ${file.lines.length} lines`))),
100
115
  );
101
116
 
102
- /** Read a batch of paths; the inner loop hoisted out of `forEach`. */
103
- const readEach = (fs: FileSystem.FileSystem, paths: ReadonlyArray<string>) =>
104
- Effect.forEach(paths, (path: string) => readFile(fs, path));
117
+ /** Reads a root's paths, several at a time, keeping their order, and counts them on the status line. */
118
+ const readEach = Effect.fn("SourceWalker.readEach")(function* (
119
+ fs: FileSystem.FileSystem,
120
+ label: string,
121
+ paths: ReadonlyArray<string>,
122
+ ) {
123
+ const status = yield* Status;
124
+ let read = 0;
125
+ return yield* Effect.forEach(
126
+ paths,
127
+ (path: string) =>
128
+ readFile(fs, path).pipe(
129
+ Effect.tap(() => {
130
+ read += 1;
131
+ return status.show(
132
+ `Reading ${label}: ${counted(read)} of ${countOf(paths.length, "file", "files")}`,
133
+ );
134
+ }),
135
+ ),
136
+ { concurrency: 16 },
137
+ );
138
+ });
105
139
 
106
- /** One root's files, in directory order: read every scannable path. */
140
+ /** One root's files, in path order: read every scannable path. */
107
141
  const readRoot = Effect.fn("SourceWalker.readRoot")(
108
- (fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
109
- Effect.flatMap(listRoot(fs, root, self, keep), (paths) => readEach(fs, paths)),
142
+ (
143
+ fs: FileSystem.FileSystem,
144
+ path: Path.Path,
145
+ root: string,
146
+ label: string,
147
+ self: string,
148
+ keep: Keep,
149
+ ) =>
150
+ Effect.flatMap(listRoot(fs, path, root, label, self, keep), (paths) =>
151
+ readEach(fs, label, paths),
152
+ ),
110
153
  );
111
154
 
112
155
  /**
@@ -125,8 +168,9 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
125
168
  });
126
169
 
127
170
  /**
128
- * The real walker: `FileSystem.readDirectory` recursively over the roots,
129
- * each file read and split into lines. Tests substitute their own tree.
171
+ * The real walker: the roots walked a directory at a time, skipped trees
172
+ * left unread, each file read and split into lines. Tests substitute their
173
+ * own tree.
130
174
  * The scan root is the current working directory, so the audit reads the
131
175
  * repository it is run in.
132
176
  */
@@ -140,11 +184,19 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
140
184
  const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
141
185
  const walk = Effect.fn("SourceWalker.files")(function* () {
142
186
  const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
143
- const trees = yield* Effect.forEach(dirs, (dir) =>
144
- readRoot(fs, path.join(root, dir), self, (relative) =>
145
- // Filter patterns are relative to the working directory, not the tree read.
146
- passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
147
- ).pipe(Effect.mapError(refused)),
187
+ const trees = yield* clearingStatus(
188
+ Effect.forEach(dirs, (dir) =>
189
+ readRoot(
190
+ fs,
191
+ path,
192
+ path.join(root, dir),
193
+ dir === "." ? "the working directory" : `${dir}/`,
194
+ self,
195
+ (relative) =>
196
+ // Filter patterns are relative to the working directory, not the tree read.
197
+ passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
198
+ ).pipe(Effect.mapError(refused)),
199
+ ),
148
200
  );
149
201
  return trees.flat();
150
202
  });
@@ -0,0 +1,31 @@
1
+ import { Context, Effect } from "effect";
2
+
3
+ export interface StatusLine {
4
+ /** Shows what the run is doing now, in place of what was shown before. */
5
+ readonly show: (text: string) => Effect.Effect<void>;
6
+ /** Takes the line away, before anything else is written. */
7
+ readonly clear: Effect.Effect<void>;
8
+ }
9
+
10
+ /**
11
+ * What a run is doing before it has anything to report: walking the
12
+ * checkout, reading files, planning. The CLI draws it as one line on
13
+ * stderr; wherever nothing provides it, as in tests, it shows nothing.
14
+ */
15
+ export const Status = Context.Reference<StatusLine>("@drkmttr/adhere/services/Status", {
16
+ defaultValue: () => ({ show: () => Effect.void, clear: Effect.void }),
17
+ });
18
+
19
+ /** `effect`, with the status line cleared when it ends, however it ends. */
20
+ export const clearingStatus = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
21
+ Effect.gen(function* () {
22
+ const status = yield* Status;
23
+ return yield* Effect.ensuring(effect, status.clear);
24
+ });
25
+
26
+ /** A count as the status line shows it: 10,333. */
27
+ export const counted = (n: number): string => n.toLocaleString("en-US");
28
+
29
+ /** A count and its noun: 1 directory, 2,679 directories. */
30
+ export const countOf = (n: number, one: string, many: string): string =>
31
+ `${counted(n)} ${n === 1 ? one : many}`;
package/src/walk.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { Effect, type FileSystem, type Path, type PlatformError } from "effect";
2
+
3
+ /** A directory entry, as the walk sorts it: a file to list, a directory to read, or neither. */
4
+ type Entry =
5
+ | { readonly kind: "file"; readonly relative: string }
6
+ | { readonly kind: "directory"; readonly relative: string }
7
+ | { readonly kind: "skipped" };
8
+
9
+ const skipped: Entry = { kind: "skipped" };
10
+
11
+ /**
12
+ * Every file under `root`, each as a path relative to it with `/` between
13
+ * segments, sorted. `enter` says, of each directory below the root by its
14
+ * relative path, whether to read into it, so a walk never lists what it would
15
+ * throw away.
16
+ *
17
+ * A directory reached through a symbolic link is never entered: pnpm's
18
+ * `node_modules` links packages into one another, and following the links
19
+ * lists every path through the dependency graph. A recursive `readdir` does
20
+ * follow them; on alchemy's checkout it passed 160 GB before the kernel
21
+ * killed it. A symbolic link to a file is listed like the file, and a link
22
+ * to nothing is left out.
23
+ *
24
+ * `progress` hears, as each directory is read, how many have been and how
25
+ * many files they held.
26
+ */
27
+ export const walkFiles = Effect.fn("walkFiles")(function* (
28
+ fs: FileSystem.FileSystem,
29
+ path: Path.Path,
30
+ root: string,
31
+ enter: (relative: string) => boolean,
32
+ progress: (directories: number, files: number) => Effect.Effect<void> = () => Effect.void,
33
+ ) {
34
+ const entryOf = (relative: string): Effect.Effect<Entry, PlatformError.PlatformError> => {
35
+ const full = path.join(root, relative);
36
+ return fs.stat(full).pipe(
37
+ Effect.flatMap((info): Effect.Effect<Entry> => {
38
+ if (info.type !== "Directory") return Effect.succeed({ kind: "file", relative });
39
+ if (!enter(relative)) return Effect.succeed(skipped);
40
+ // Only a link can be read as one, so a failed read is a real directory.
41
+ return fs.readLink(full).pipe(
42
+ Effect.match({
43
+ onFailure: (): Entry => ({ kind: "directory", relative }),
44
+ onSuccess: (): Entry => skipped,
45
+ }),
46
+ );
47
+ }),
48
+ Effect.catchIf(
49
+ (problem) => problem.reason._tag === "NotFound",
50
+ () => Effect.succeed(skipped),
51
+ ),
52
+ );
53
+ };
54
+
55
+ const files: Array<string> = [];
56
+ let directories = 0;
57
+ let level: ReadonlyArray<string> = [""];
58
+ while (level.length > 0) {
59
+ const next: Array<string> = [];
60
+ yield* Effect.forEach(
61
+ level,
62
+ (directory) =>
63
+ fs.readDirectory(directory === "" ? root : path.join(root, directory)).pipe(
64
+ Effect.flatMap((names) =>
65
+ Effect.forEach(
66
+ names,
67
+ (name) => entryOf(directory === "" ? name : `${directory}/${name}`),
68
+ { concurrency: 16 },
69
+ ),
70
+ ),
71
+ Effect.flatMap((entries) => {
72
+ for (const entry of entries) {
73
+ if (entry.kind === "file") files.push(entry.relative);
74
+ else if (entry.kind === "directory") next.push(entry.relative);
75
+ }
76
+ directories += 1;
77
+ return progress(directories, files.length);
78
+ }),
79
+ ),
80
+ { concurrency: 8, discard: true },
81
+ );
82
+ level = next;
83
+ }
84
+ return files.sort();
85
+ });
package/src/wording.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Example, examplesOf, type Rule } from "#config.ts";
2
- import type { RuleEntry } from "#rules.ts";
2
+ import { type RuleEntry, shownId } from "#rules.ts";
3
3
 
4
4
  type Word = Example["word"];
5
5
 
@@ -83,7 +83,7 @@ export const formatStrays = (strays: ReadonlyArray<Stray>, root: string): Readon
83
83
  : [
84
84
  `${strays.length} ${strays.length === 1 ? "rule is" : "rules are"} not worded as the rule writing tips recommend:`,
85
85
  ...strays.flatMap(({ entry, straying }) => [
86
- ` ${entry.id}${entry.file === undefined ? "" : ` (${displayPath(entry.file, root)})`}`,
86
+ ` ${shownId(entry)}${entry.file === undefined ? "" : ` (${displayPath(entry.file, root)})`}`,
87
87
  ...straying.map((one) => ` ${sentenceOf(one)}`),
88
88
  ]),
89
89
  `Rule writing tips: ${TIPS_URL}`,
@@ -9,6 +9,7 @@ import {
9
9
  JevUnavailable,
10
10
  type Judged,
11
11
  judgeQuestion,
12
+ judgeLoad,
12
13
  judgeRequests,
13
14
  linterQuestion,
14
15
  locateRequests,
@@ -16,12 +17,16 @@ import {
16
17
  tokensOf,
17
18
  } from "#services/Jev.ts";
18
19
  import { SourceWalker } from "#services/SourceWalker.ts";
20
+ import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
19
21
  import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
22
+ import { priceOf } from "#pricing.ts";
20
23
  import { applicableRules } from "#rules.ts";
21
24
  import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
22
25
 
23
26
  export interface Finding {
24
27
  readonly rule: RuleId;
28
+ /** The preset the rule came from, when it is not the project's own. */
29
+ readonly preset?: string;
25
30
  readonly description: string;
26
31
  readonly examples: Examples;
27
32
  readonly file: string;
@@ -59,6 +64,8 @@ interface PreparedRule {
59
64
  readonly rule: Rule;
60
65
  readonly fingerprint: string;
61
66
  readonly threshold: number;
67
+ /** The preset the rule came from, when it is not the project's own. */
68
+ readonly preset?: string;
62
69
  }
63
70
 
64
71
  const fileResult = (
@@ -107,6 +114,10 @@ export interface AuditPlan {
107
114
  readonly deferred: number;
108
115
  /** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
109
116
  readonly requests: number;
117
+ /** About how many input tokens those requests carry, which Jev charges for. */
118
+ readonly tokens: number;
119
+ /** The model's price per million input tokens, in dollars, when adhere knows it. */
120
+ readonly price?: number;
110
121
  }
111
122
 
112
123
  /** A file finished, for a progress counter: the requests it took and the findings it has. */
@@ -174,13 +185,15 @@ export const planAudit = (
174
185
 
175
186
  const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
176
187
  const rules = configuredRules(file.path);
188
+ // Hashed even when skipped, so a prune keeps what other runs know of its content.
189
+ const hash = yield* sha256(file.lines.join("\n"));
177
190
  // Too long for Jev's context: skipped and counted, rather than refused mid-run.
178
191
  if (!fits(file.lines, rules)) {
179
192
  yield* Effect.logTrace(`plan ${file.path}: too long for Jev's context, skipped`);
180
193
  return {
181
194
  file,
182
195
  skipped: true,
183
- hash: "",
196
+ hash,
184
197
  prepared: {},
185
198
  kept: {},
186
199
  pending: {},
@@ -191,13 +204,19 @@ export const planAudit = (
191
204
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
192
205
  Object.entries(rules),
193
206
  ([id, rule]) =>
194
- Effect.map(
195
- fingerprintOf(config.model, rule),
196
- (fingerprint) =>
197
- [id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
198
- ),
207
+ Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
208
+ const preset = presetOfRule.get(rule);
209
+ return [
210
+ id,
211
+ {
212
+ rule,
213
+ fingerprint,
214
+ threshold: rule.threshold ?? config.threshold,
215
+ ...(preset === undefined ? {} : { preset }),
216
+ },
217
+ ] as const;
218
+ }),
199
219
  ).pipe(Effect.map(Record.fromEntries));
200
- const hash = yield* sha256(file.lines.join("\n"));
201
220
  const answers = yield* cache.get(hash, file.path);
202
221
  const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
203
222
  const answer = answers[fingerprint];
@@ -219,6 +238,12 @@ export const planAudit = (
219
238
  } satisfies FilePlan;
220
239
  });
221
240
 
241
+ // Each preset's rule, with the preset it came from, for its findings to name.
242
+ const presetOfRule = new Map(
243
+ (config.scopedRules ?? []).flatMap((entry) =>
244
+ entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
245
+ ),
246
+ );
222
247
  // Preset rules are not the project's to change, so the linter check leaves them out.
223
248
  const presetRules = new Set(
224
249
  (config.scopedRules ?? []).flatMap((entry) =>
@@ -281,13 +306,33 @@ export const planAudit = (
281
306
  });
282
307
 
283
308
  const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
284
- const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
285
- concurrency: 8,
286
- });
309
+ const status = yield* Status;
310
+ let done = 0;
311
+ const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
312
+ Effect.forEach(
313
+ sorted,
314
+ (file) =>
315
+ planFile(file).pipe(
316
+ Effect.tap(() => {
317
+ done += 1;
318
+ return status.show(
319
+ `Planning: ${counted(done)} of ${countOf(sorted.length, "file", "files")}`,
320
+ );
321
+ }),
322
+ ),
323
+ { concurrency: 8 },
324
+ ),
325
+ );
287
326
  const limited =
288
327
  options.limit === undefined ? everything : withinLimit(everything, options.limit);
289
328
  const planned = yield* withSamples(limited);
290
329
  const judged = planned.filter((plan) => !plan.skipped);
330
+ const loads = judged.map((plan) =>
331
+ isEmpty(plan.pending)
332
+ ? { requests: 0, tokens: 0 }
333
+ : judgeLoad(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
334
+ );
335
+ const price = priceOf(config.model);
291
336
  const total = (count: (plan: FilePlan) => number) =>
292
337
  judged.reduce((sum, plan) => sum + count(plan), 0);
293
338
  return {
@@ -297,11 +342,9 @@ export const planAudit = (
297
342
  cached: total((plan) => sizeOf(plan.kept)),
298
343
  skipped: planned.length - judged.length,
299
344
  deferred: total((plan) => plan.deferred),
300
- requests: total((plan) =>
301
- isEmpty(plan.pending)
302
- ? 0
303
- : judgeRequests(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
304
- ),
345
+ requests: loads.reduce((sum, load) => sum + load.requests, 0),
346
+ tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
347
+ ...(price === undefined ? {} : { price }),
305
348
  };
306
349
  });
307
350
 
@@ -401,6 +444,7 @@ export const executeAudit = (
401
444
  ? [
402
445
  {
403
446
  rule: id,
447
+ ...(k.preset === undefined ? {} : { preset: k.preset }),
404
448
  description: k.rule.description,
405
449
  examples: examplesOf(k.rule),
406
450
  file: file.path,
@@ -486,22 +530,21 @@ export const executeAudit = (
486
530
  });
487
531
 
488
532
  /**
489
- * Deletes from the cache what the files and rules of a run no longer need:
490
- * answers about content no file has, answers to rule texts no rule asks, and
491
- * tallies of rules gone. Only after a run that read every file; a run narrowed
492
- * by `--filter` cannot tell what the files it left out need.
533
+ * Deletes from the cache the answers about content no file has anymore.
534
+ * Answers to rules the run left out stay, since a run with other presets or
535
+ * rules asks them. Only after a run that read every file; a run narrowed by
536
+ * `--filter` cannot tell what content the files it left out have.
493
537
  */
494
538
  export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
495
539
  const config = yield* AdhereConfig;
496
540
  const cache = yield* AuditCache;
497
- const read = plan.files.filter((file) => !file.skipped);
498
- const prepared = read.flatMap((file) => Object.values(file.prepared));
541
+ const prepared = plan.files.flatMap((file) => Object.values(file.prepared));
499
542
  const tallies = yield* Effect.forEach(new Set(prepared.map(({ rule }) => rule)), (rule) =>
500
543
  tallyKeyOf(config.model, rule),
501
544
  );
502
545
  const deleted = yield* cache.prune({
503
- hashes: new Set(read.map((file) => file.hash)),
504
- fingerprints: new Set(prepared.map(({ fingerprint }) => fingerprint)),
546
+ // A file too long to judge counts: a run with shorter questions judges it.
547
+ hashes: new Set(plan.files.map((file) => file.hash)),
505
548
  tallies: new Set(tallies),
506
549
  });
507
550
  yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);
@@ -1,4 +1,5 @@
1
1
  import { type Kind, tokenize } from "#highlight.ts";
2
+ import { shownId } from "#rules.ts";
2
3
  import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit.ts";
3
4
 
4
5
  /**
@@ -91,7 +92,7 @@ const header = (finding: Finding): Line => [
91
92
  span(" "),
92
93
  span("×", "error"),
93
94
  span(" "),
94
- span(finding.rule, "error"),
95
+ span(shownId({ id: finding.rule, preset: finding.preset }), "error"),
95
96
  span(" ("),
96
97
  span(finding.probability.toFixed(2), "probability"),
97
98
  span("): "),
@@ -224,6 +225,41 @@ const durationAt = (requests: number, rpm: number): string => {
224
225
  : counted(Math.round(seconds / 60), "minute", "minutes");
225
226
  };
226
227
 
228
+ /** A number to one decimal place, without a trailing .0: 3.2, 43. */
229
+ const tenths = (n: number): string => Number(n.toFixed(1)).toLocaleString("en-US");
230
+
231
+ /** A token count as the plan says it: 612, 41,000, 3.2 million, 43 million, 1.1 billion. */
232
+ const tokensSaid = (tokens: number): string =>
233
+ tokens >= 1e9
234
+ ? `${tenths(tokens / 1e9)} billion`
235
+ : tokens >= 1e6
236
+ ? `${tenths(tokens / 1e6)} million`
237
+ : tokens >= 1e3
238
+ ? (Math.round(tokens / 1e3) * 1e3).toLocaleString("en-US")
239
+ : tokens.toLocaleString("en-US");
240
+
241
+ /** An estimated cost as the plan says it: under $0.01, about $0.14, about $1,234. */
242
+ const dollars = (amount: number): string =>
243
+ amount < 0.01
244
+ ? "under $0.01"
245
+ : amount < 100
246
+ ? `about $${amount.toFixed(2)}`
247
+ : `about $${Math.round(amount).toLocaleString("en-US")}`;
248
+
249
+ /** What judging the plan costs at the model's price, when adhere knows it. */
250
+ const costOf = (plan: AuditPlan): number | undefined =>
251
+ plan.price === undefined ? undefined : (plan.tokens / 1e6) * plan.price;
252
+
253
+ /** The plan's input tokens and their cost; locating findings comes on top, since it depends on the findings. */
254
+ const costLine = (plan: AuditPlan): string => {
255
+ const cost = costOf(plan);
256
+ const priced =
257
+ cost === undefined || plan.price === undefined
258
+ ? ""
259
+ : `: ${dollars(cost)} at $${plan.price} per million`;
260
+ return `Those carry about ${tokensSaid(plan.tokens)} input tokens${priced}, and more for locating findings.`;
261
+ };
262
+
227
263
  export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyArray<string> => {
228
264
  const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
229
265
  const skipped =
@@ -260,12 +296,16 @@ export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyA
260
296
  return [
261
297
  found,
262
298
  `Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.${pace}${waiting}`,
299
+ ...(plan.tokens > 0 ? [costLine(plan)] : []),
263
300
  ];
264
301
  };
265
302
 
266
303
  /** The question before a run sends anything. */
267
- export const sendQuestion = (plan: AuditPlan): string =>
268
- `Send ${counted(plan.requests, "request", "requests")} to Jev?`;
304
+ export const sendQuestion = (plan: AuditPlan): string => {
305
+ const cost = costOf(plan);
306
+ const priced = cost === undefined || plan.tokens === 0 ? "" : `, ${dollars(cost)}`;
307
+ return `Send ${counted(plan.requests, "request", "requests")} to Jev${priced}?`;
308
+ };
269
309
 
270
310
  /** A run's progress so far: every file it finished, with the requests and findings they added up to. */
271
311
  export interface Progress {