@drkmttr/adhere 0.9.4 → 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
@@ -111,9 +114,17 @@ what judging takes:
111
114
  ```text
112
115
  200 files and 14 rules: 2800 checks, 1400 cached.
113
116
  Judging the other 1400 takes 200 requests to Jev, plus 1 or more for each file with a finding.
114
- ? 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)
115
119
  ```
116
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
+
117
128
  It asks only with a terminal on stdin and stdout, and never when the cache
118
129
  answers every check; `--yes` sends without asking. While it runs, a counter on
119
130
  stderr shows the files done, the requests sent, and the findings so far, and
@@ -377,7 +388,8 @@ A preset has the shape of a config without `presets`: `rules`, and optionally
377
388
  `model` and `threshold`. Name presets in the config's `presets`, or on the
378
389
  command line with `--preset`, repeated or separated by commas, as in
379
390
  `--preset effect,alchemy`, in which case the config file is optional. Presets
380
- named in both places apply together. There are two:
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:
381
393
 
382
394
  - `effect`: Markdown rules in [`presets/effect/`](./presets/effect/), lifted
383
395
  from the [effect-solutions](https://github.com/kitlangton/effect-solutions)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.9.4",
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.4",
72
- "@drkmttr/adhere-darwin-x64": "0.9.4",
73
- "@drkmttr/adhere-linux-arm64": "0.9.4",
74
- "@drkmttr/adhere-linux-x64": "0.9.4",
75
- "@drkmttr/adhere-win32-x64": "0.9.4"
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
  }
@@ -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
@@ -20,10 +20,19 @@ export interface RuleEntry {
20
20
  readonly rule: Rule;
21
21
  readonly scope: string;
22
22
  readonly file?: string;
23
- /** 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. */
24
24
  readonly preset?: string;
25
25
  }
26
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
+
27
36
  export type RuleSet = ReadonlyArray<RuleEntry>;
28
37
 
29
38
  const ADHERE_SEGMENT = ".adhere";
@@ -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`}`,
@@ -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,
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,
@@ -18,11 +19,14 @@ import {
18
19
  import { SourceWalker } from "#services/SourceWalker.ts";
19
20
  import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
20
21
  import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
22
+ import { priceOf } from "#pricing.ts";
21
23
  import { applicableRules } from "#rules.ts";
22
24
  import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
23
25
 
24
26
  export interface Finding {
25
27
  readonly rule: RuleId;
28
+ /** The preset the rule came from, when it is not the project's own. */
29
+ readonly preset?: string;
26
30
  readonly description: string;
27
31
  readonly examples: Examples;
28
32
  readonly file: string;
@@ -60,6 +64,8 @@ interface PreparedRule {
60
64
  readonly rule: Rule;
61
65
  readonly fingerprint: string;
62
66
  readonly threshold: number;
67
+ /** The preset the rule came from, when it is not the project's own. */
68
+ readonly preset?: string;
63
69
  }
64
70
 
65
71
  const fileResult = (
@@ -108,6 +114,10 @@ export interface AuditPlan {
108
114
  readonly deferred: number;
109
115
  /** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
110
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;
111
121
  }
112
122
 
113
123
  /** A file finished, for a progress counter: the requests it took and the findings it has. */
@@ -194,11 +204,18 @@ export const planAudit = (
194
204
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
195
205
  Object.entries(rules),
196
206
  ([id, rule]) =>
197
- Effect.map(
198
- fingerprintOf(config.model, rule),
199
- (fingerprint) =>
200
- [id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
201
- ),
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
+ }),
202
219
  ).pipe(Effect.map(Record.fromEntries));
203
220
  const answers = yield* cache.get(hash, file.path);
204
221
  const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
@@ -221,6 +238,12 @@ export const planAudit = (
221
238
  } satisfies FilePlan;
222
239
  });
223
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
+ );
224
247
  // Preset rules are not the project's to change, so the linter check leaves them out.
225
248
  const presetRules = new Set(
226
249
  (config.scopedRules ?? []).flatMap((entry) =>
@@ -304,6 +327,12 @@ export const planAudit = (
304
327
  options.limit === undefined ? everything : withinLimit(everything, options.limit);
305
328
  const planned = yield* withSamples(limited);
306
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);
307
336
  const total = (count: (plan: FilePlan) => number) =>
308
337
  judged.reduce((sum, plan) => sum + count(plan), 0);
309
338
  return {
@@ -313,11 +342,9 @@ export const planAudit = (
313
342
  cached: total((plan) => sizeOf(plan.kept)),
314
343
  skipped: planned.length - judged.length,
315
344
  deferred: total((plan) => plan.deferred),
316
- requests: total((plan) =>
317
- isEmpty(plan.pending)
318
- ? 0
319
- : judgeRequests(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
320
- ),
345
+ requests: loads.reduce((sum, load) => sum + load.requests, 0),
346
+ tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
347
+ ...(price === undefined ? {} : { price }),
321
348
  };
322
349
  });
323
350
 
@@ -417,6 +444,7 @@ export const executeAudit = (
417
444
  ? [
418
445
  {
419
446
  rule: id,
447
+ ...(k.preset === undefined ? {} : { preset: k.preset }),
420
448
  description: k.rule.description,
421
449
  examples: examplesOf(k.rule),
422
450
  file: file.path,
@@ -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 {