@drkmttr/adhere 0.7.0 → 0.7.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.
package/README.md CHANGED
@@ -75,6 +75,9 @@ adhere lint # audit the working directory
75
75
  adhere lint --preset effect # add a built-in rule set; the config becomes optional
76
76
  adhere lint --threshold 0.9 # replace the config's threshold; per-rule thresholds still apply
77
77
  adhere lint --yes # send the requests without asking first
78
+ adhere lint --limit 500 # judge at most 500 checks; the rest wait for the next run
79
+ adhere lint --rpm 30 # send at most 30 requests a minute
80
+ adhere lint --filter 'src/**' # read only the files a glob matches
78
81
  adhere validate # load the config and rules, ask Jev whether any contradict
79
82
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
80
83
  adhere login # save a TypeSafe AI API key for later runs
@@ -104,6 +107,21 @@ the report goes to stdout. A request Jev refuses stops the run with the file,
104
107
  the HTTP status, and Jev's reason. Files finished before it are cached, so
105
108
  running again continues from there.
106
109
 
110
+ ### Limiting a run
111
+
112
+ Three flags bound what a run does. `--limit <checks>` judges at most that many
113
+ checks, taken in path order; the rest wait, and since judgments are cached, the
114
+ next run with the same limit picks up where this one stopped. `--limit 0` shows
115
+ the plan and judges nothing. `--rpm <requests>` sends at most that many requests
116
+ to Jev a minute, evenly spaced, retries included, and the plan says about how
117
+ long they take. `--filter <glob>` reads only the files whose path from the
118
+ working directory matches, such as `src/**` or `**/*.service.ts`; repeat it for
119
+ more, and start a pattern with `!` to leave out what it matches. Together:
120
+
121
+ ```sh
122
+ adhere lint --filter 'packages/api/**' --filter '!**/generated/**' --limit 200 --rpm 30
123
+ ```
124
+
107
125
  ### What gets read
108
126
 
109
127
  `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "private": false,
5
5
  "description": "Lints a repository against the rules 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.7.0",
71
- "@drkmttr/adhere-darwin-x64": "0.7.0",
72
- "@drkmttr/adhere-linux-arm64": "0.7.0",
73
- "@drkmttr/adhere-linux-x64": "0.7.0",
74
- "@drkmttr/adhere-win32-x64": "0.7.0"
70
+ "@drkmttr/adhere-darwin-arm64": "0.7.1",
71
+ "@drkmttr/adhere-darwin-x64": "0.7.1",
72
+ "@drkmttr/adhere-linux-arm64": "0.7.1",
73
+ "@drkmttr/adhere-linux-x64": "0.7.1",
74
+ "@drkmttr/adhere-win32-x64": "0.7.1"
75
75
  }
76
76
  }
package/src/cli.ts CHANGED
@@ -68,6 +68,33 @@ const threshold = Flag.float("threshold").pipe(
68
68
  ),
69
69
  );
70
70
 
71
+ const limit = Flag.integer("limit").pipe(
72
+ Flag.filter(
73
+ (checks) => checks >= 0,
74
+ (checks) => `--limit is a number of checks, 0 or more, not ${checks}.`,
75
+ ),
76
+ Flag.optional,
77
+ Flag.withDescription(
78
+ "Judge at most this many checks. The rest wait for a later run, which picks up where this one stopped, since judgments are cached. --limit 0 shows the plan and judges nothing.",
79
+ ),
80
+ );
81
+
82
+ const rpm = Flag.integer("rpm").pipe(
83
+ Flag.filter(
84
+ (requests) => requests > 0,
85
+ (requests) => `--rpm is a number of requests a minute, more than 0, not ${requests}.`,
86
+ ),
87
+ Flag.optional,
88
+ Flag.withDescription("Send at most this many requests to Jev a minute, evenly spaced."),
89
+ );
90
+
91
+ const filter = Flag.string("filter").pipe(
92
+ Flag.atLeast(0),
93
+ Flag.withDescription(
94
+ "Read only files whose path from the working directory matches this glob, such as 'src/**' or '**/*.service.ts'. Repeat it for more; a pattern starting with ! leaves out what it matches.",
95
+ ),
96
+ );
97
+
71
98
  const yes = Flag.boolean("yes").pipe(
72
99
  Flag.withAlias("y"),
73
100
  Flag.withDefault(false),
@@ -114,53 +141,61 @@ const force = Flag.boolean("force").pipe(
114
141
  const chosenPresets = (flag: Option.Option<PresetName>): ReadonlyArray<PresetName> =>
115
142
  Option.isSome(flag) ? [flag.value] : [];
116
143
 
117
- export const auditLayer = (overrides: Overrides) =>
144
+ export const auditLayer = (overrides: Overrides, filter: ReadonlyArray<string> = []) =>
118
145
  Layer.mergeAll(
119
- SourceWalkerLive,
146
+ SourceWalkerLive(filter),
120
147
  AuditCacheLive,
121
148
  JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
122
149
  ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
123
150
 
124
151
  /** `adhere lint`: the audit. */
125
- export const lintCommand = Command.make("lint", { preset, threshold, yes }, (input) =>
126
- Effect.gen(function* () {
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 }),
152
+ export const lintCommand = Command.make(
153
+ "lint",
154
+ { preset, threshold, yes, limit, rpm, filter },
155
+ (input) =>
156
+ Effect.gen(function* () {
157
+ const stdio = yield* Stdio.Stdio;
158
+ const plan = yield* planAudit({ limit: Option.getOrUndefined(input.limit) });
159
+ const limits = { filter: input.filter, rpm: Option.getOrUndefined(input.rpm) };
160
+ yield* toStderr(`${describePlan(plan, limits).join("\n")}\n`);
161
+ // A prompt draws on stdout, so it needs a terminal at both ends: none when
162
+ // the report is redirected, or in CI.
163
+ const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
164
+ if (plan.requests > 0 && interactive && !input.yes) {
165
+ const send = yield* Prompt.run(
166
+ Prompt.confirm({ message: sendQuestion(plan), initial: true }),
167
+ );
168
+ if (!send) {
169
+ yield* toStderr("Nothing sent.\n");
170
+ return yield* Cancelled.make({});
171
+ }
172
+ }
173
+ const counter = counterFor(plan);
174
+ yield* counter.start;
175
+ const result = yield* executeAudit(plan, counter.update).pipe(
176
+ Effect.onExit((exit) => counter.finish(Exit.isFailure(exit))),
136
177
  );
137
- if (!send) {
138
- yield* toStderr("Nothing sent.\n");
139
- return yield* Cancelled.make({});
178
+ const color = yield* stdio.stdoutIsTerminal;
179
+ const path = yield* Path.Path;
180
+ // One write: separate Console.log calls have interleaved out of order here.
181
+ yield* Console.log(render(result, { color, root: path.resolve() }).join("\n"));
182
+ if (result.findings.length > 0) {
183
+ yield* FindingsReported.make({ count: result.findings.length });
140
184
  }
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
- );
147
- const color = yield* stdio.stdoutIsTerminal;
148
- const path = yield* Path.Path;
149
- // One write: separate Console.log calls have interleaved out of order here.
150
- yield* Console.log(render(result, { color, root: path.resolve() }).join("\n"));
151
- if (result.findings.length > 0) {
152
- yield* FindingsReported.make({ count: result.findings.length });
153
- }
154
- }),
185
+ }),
155
186
  ).pipe(
156
187
  Command.withDescription(
157
188
  "Audit the working directory against the rules in .adhere/ or a preset, judged by Jev.",
158
189
  ),
159
190
  Command.provide((input) =>
160
- auditLayer({
161
- presets: chosenPresets(input.preset),
162
- threshold: Option.getOrUndefined(input.threshold),
163
- }),
191
+ auditLayer(
192
+ {
193
+ presets: chosenPresets(input.preset),
194
+ threshold: Option.getOrUndefined(input.threshold),
195
+ rpm: Option.getOrUndefined(input.rpm),
196
+ },
197
+ input.filter,
198
+ ),
164
199
  ),
165
200
  );
166
201
 
package/src/config.ts CHANGED
@@ -148,6 +148,8 @@ export interface ResolvedConfig {
148
148
  readonly threshold: number;
149
149
  readonly rules: Rules;
150
150
  readonly scopedRules?: RuleSet;
151
+ /** Requests to Jev a minute, at most. Unset, requests go out as fast as they are made. */
152
+ readonly rpm?: number;
151
153
  }
152
154
 
153
155
  /** Command-line values that apply on top of the config file. */
@@ -155,6 +157,8 @@ export interface Overrides {
155
157
  readonly presets?: ReadonlyArray<PresetName>;
156
158
  /** Replaces the config's global threshold. Per-rule thresholds still win. */
157
159
  readonly threshold?: number;
160
+ /** Throttles requests to Jev to at most this many a minute. */
161
+ readonly rpm?: number;
158
162
  }
159
163
 
160
164
  /** The presets a run applies, command-line ones first. */
@@ -183,6 +187,7 @@ export const resolveConfig = (
183
187
  model: config.model ?? last("model") ?? DEFAULT_MODEL,
184
188
  threshold: overrides.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
185
189
  rules: Object.assign({}, ...applied.map((preset) => preset.rules), config.rules),
190
+ ...(overrides.rpm === undefined ? {} : { rpm: overrides.rpm }),
186
191
  };
187
192
  };
188
193
 
@@ -19,9 +19,10 @@ import {
19
19
  requestsOf,
20
20
  type Rules,
21
21
  } from "#services/Jev.ts";
22
- import { type Cause, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
22
+ import { type Cause, Duration, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
23
23
  import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
24
24
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
25
+ import { RateLimiter } from "effect/unstable/persistence";
25
26
 
26
27
  const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
27
28
 
@@ -53,7 +54,9 @@ const isOverflow = (body: string): boolean =>
53
54
  * the run with its status and what Jev said, since the status alone does not
54
55
  * say why, and a request that got no answer refuses with the cause.
55
56
  */
56
- const failed = (problem: HttpClientError.HttpClientError | Cause.TimeoutError) => {
57
+ const failed = (
58
+ problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
59
+ ) => {
57
60
  if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
58
61
  return Effect.fail(refused(`System One request failed: ${problem.message}`));
59
62
  }
@@ -79,8 +82,27 @@ export const JevLive = Layer.effect(Jev)(
79
82
  Effect.gen(function* () {
80
83
  const config = yield* AdhereConfig;
81
84
  const credentials = yield* Credentials;
82
- const client = (yield* HttpClient.HttpClient).pipe(
83
- HttpClient.filterStatusOk,
85
+ const http = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
86
+ // `--rpm`: one request per 60/rpm seconds, evenly spaced rather than in a
87
+ // burst each minute. Before the retries, so a retry waits its turn too.
88
+ type Failure = HttpClientError.HttpClientError | RateLimiter.RateLimiterError;
89
+ const paced: HttpClient.HttpClient.With<Failure> =
90
+ config.rpm === undefined
91
+ ? // The same client, typed to fail the ways the throttled one can.
92
+ HttpClient.transformResponse(
93
+ http,
94
+ (response): Effect.Effect<HttpClientResponse.HttpClientResponse, Failure> => response,
95
+ )
96
+ : http.pipe(
97
+ HttpClient.withRateLimiter({
98
+ limiter: yield* RateLimiter.RateLimiter,
99
+ key: "jev",
100
+ algorithm: "token-bucket",
101
+ limit: 1,
102
+ window: Duration.millis(60_000 / config.rpm),
103
+ }),
104
+ );
105
+ const client = paced.pipe(
84
106
  HttpClient.transformResponse(Effect.timeout("30 seconds")),
85
107
  HttpClient.retryTransient({
86
108
  schedule: Schedule.exponential("500 millis"),
@@ -172,4 +194,4 @@ export const JevLive = Layer.effect(Jev)(
172
194
 
173
195
  return Jev.of({ judge, locate, conflicts, contradicts });
174
196
  }),
175
- );
197
+ ).pipe(Layer.provide(RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory))));
@@ -1,3 +1,4 @@
1
+ import { matchesGlob } from "node:path";
1
2
  import { Context, Effect, FileSystem, Layer, Path } from "effect";
2
3
  import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
3
4
  import type { ScannedFile } from "#models/Audit.ts";
@@ -31,6 +32,22 @@ export const isInSkippedTree = (relative: string): boolean =>
31
32
  .split(/[\\/]/)
32
33
  .some((segment) => segment === ADHERE_DIRECTORY || SKIPPED_DIRECTORIES.has(segment));
33
34
 
35
+ /**
36
+ * Whether a path, relative to the working directory, passes a run's filter.
37
+ * A pattern is a glob; one starting with `!` leaves out what it matches. A
38
+ * path passes when no `!` pattern matches it and either another pattern does
39
+ * or there are none.
40
+ */
41
+ export const passesFilter = (relative: string, patterns: ReadonlyArray<string>): boolean => {
42
+ const path = relative.replaceAll("\\", "/");
43
+ const matches = (pattern: string) => matchesGlob(path, pattern);
44
+ const include = patterns.filter((pattern) => !pattern.startsWith("!"));
45
+ const exclude = patterns
46
+ .filter((pattern) => pattern.startsWith("!"))
47
+ .map((pattern) => pattern.slice(1));
48
+ return (include.length === 0 || include.some(matches)) && !exclude.some(matches);
49
+ };
50
+
34
51
  const isInside = (file: string, root: string): boolean =>
35
52
  file === root || file.startsWith(`${root}/`);
36
53
 
@@ -49,18 +66,21 @@ const refused = (problem: { readonly message: string }): WalkUnavailable =>
49
66
  /** `readdir(recursive)` yields paths relative to the root it read: root them. */
50
67
  const rooted = (root: string, paths: ReadonlyArray<string>) => paths.map((rel) => `${root}/${rel}`);
51
68
 
69
+ /** A test on a path relative to the root being read: whether the run wants it. */
70
+ type Keep = (relative: string) => boolean;
71
+
52
72
  /** What the root actually contains: only paths the audit reads. */
53
- const scannable = (root: string, paths: ReadonlyArray<string>, self: string) =>
73
+ const scannable = (root: string, paths: ReadonlyArray<string>, self: string, keep: Keep) =>
54
74
  rooted(
55
75
  root,
56
- paths.filter((relative) => !isInSkippedTree(relative)),
76
+ paths.filter((relative) => !isInSkippedTree(relative) && keep(relative)),
57
77
  ).filter((file) => isScannable(file, self));
58
78
 
59
79
  /** One root's paths, filtered to what the audit reads. */
60
80
  const listRoot = Effect.fn("SourceWalker.listRoot")(
61
- (fs: FileSystem.FileSystem, root: string, self: string) =>
81
+ (fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
62
82
  Effect.map(fs.readDirectory(root, { recursive: true }), (paths) =>
63
- scannable(root, paths, self),
83
+ scannable(root, paths, self, keep),
64
84
  ),
65
85
  );
66
86
 
@@ -78,8 +98,8 @@ const readEach = (fs: FileSystem.FileSystem, paths: ReadonlyArray<string>) =>
78
98
 
79
99
  /** One root's files, in directory order: read every scannable path. */
80
100
  const readRoot = Effect.fn("SourceWalker.readRoot")(
81
- (fs: FileSystem.FileSystem, root: string, self: string) =>
82
- Effect.flatMap(listRoot(fs, root, self), (paths) => readEach(fs, paths)),
101
+ (fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
102
+ Effect.flatMap(listRoot(fs, root, self, keep), (paths) => readEach(fs, paths)),
83
103
  );
84
104
 
85
105
  /**
@@ -103,20 +123,24 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
103
123
  * The scan root is the current working directory, so the audit reads the
104
124
  * repository it is run in.
105
125
  */
106
- export const SourceWalkerLive = Layer.effect(SourceWalker)(
107
- Effect.gen(function* () {
108
- const fs = yield* FileSystem.FileSystem;
109
- const path = yield* Path.Path;
110
- // No segments: Path resolves against the working directory.
111
- const root = path.resolve();
112
- const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
113
- const walk = Effect.fn("SourceWalker.files")(function* () {
114
- const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
115
- const trees = yield* Effect.forEach(dirs, (dir) =>
116
- readRoot(fs, path.join(root, dir), self).pipe(Effect.mapError(refused)),
117
- );
118
- return trees.flat();
119
- });
120
- return SourceWalker.of({ files: walk() });
121
- }),
122
- );
126
+ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
127
+ Layer.effect(SourceWalker)(
128
+ Effect.gen(function* () {
129
+ const fs = yield* FileSystem.FileSystem;
130
+ const path = yield* Path.Path;
131
+ // No segments: Path resolves against the working directory.
132
+ const root = path.resolve();
133
+ const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
134
+ const walk = Effect.fn("SourceWalker.files")(function* () {
135
+ const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
136
+ const trees = yield* Effect.forEach(dirs, (dir) =>
137
+ readRoot(fs, path.join(root, dir), self, (relative) =>
138
+ // Filter patterns are relative to the working directory, not the tree read.
139
+ passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
140
+ ).pipe(Effect.mapError(refused)),
141
+ );
142
+ return trees.flat();
143
+ });
144
+ return SourceWalker.of({ files: walk() });
145
+ }),
146
+ );
@@ -30,11 +30,13 @@ export interface AuditResult {
30
30
  readonly judged: number;
31
31
  readonly cached: number;
32
32
  readonly skipped: number;
33
+ /** Files whose checks `--limit` left for a later run, none judged in this one. */
34
+ readonly waiting: number;
33
35
  readonly findings: ReadonlyArray<Finding>;
34
36
  }
35
37
 
36
38
  interface FileResult {
37
- readonly status: "judged" | "cached" | "skipped";
39
+ readonly status: "judged" | "cached" | "skipped" | "waiting";
38
40
  readonly findings: ReadonlyArray<Finding>;
39
41
  }
40
42
 
@@ -69,6 +71,8 @@ export interface FilePlan {
69
71
  /** Cached judgments still valid for the file's content and the rule's text. */
70
72
  readonly kept: Readonly<Record<RuleId, Judgment>>;
71
73
  readonly pending: Readonly<Record<RuleId, PreparedRule>>;
74
+ /** Pending checks `--limit` leaves for a later run. */
75
+ readonly deferred: number;
72
76
  }
73
77
 
74
78
  /** A run worked out from the files, the rules, and the cache, before any request. */
@@ -82,6 +86,8 @@ export interface AuditPlan {
82
86
  readonly cached: number;
83
87
  /** Files too long to judge. */
84
88
  readonly skipped: number;
89
+ /** Checks `--limit` leaves for a later run. */
90
+ readonly deferred: number;
85
91
  /** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
86
92
  readonly requests: number;
87
93
  }
@@ -108,69 +114,110 @@ const inFile =
108
114
  : problem,
109
115
  );
110
116
 
111
- export const planAudit: Effect.Effect<
117
+ /**
118
+ * The plan with at most `limit` checks left to judge, taken in path order.
119
+ * Past the limit a file's other checks wait for a later run; judgments are
120
+ * cached, so a rerun picks up where this one stopped.
121
+ */
122
+ const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyArray<FilePlan> => {
123
+ let left = limit;
124
+ return planned.map((plan) => {
125
+ const ids = Object.keys(plan.pending);
126
+ const taken = new Set(ids.slice(0, left));
127
+ left -= taken.size;
128
+ return taken.size === ids.length
129
+ ? plan
130
+ : {
131
+ ...plan,
132
+ pending: Record.filter(plan.pending, (_, id) => taken.has(id)),
133
+ deferred: ids.length - taken.size,
134
+ };
135
+ });
136
+ };
137
+
138
+ export interface PlanOptions {
139
+ /** Judge at most this many checks; the rest wait for a later run. */
140
+ readonly limit?: number;
141
+ }
142
+
143
+ export const planAudit = (
144
+ options: PlanOptions = {},
145
+ ): Effect.Effect<
112
146
  AuditPlan,
113
147
  WalkUnavailable,
114
148
  AdhereConfig | AuditCache | Crypto.Crypto | SourceWalker
115
- > = Effect.gen(function* () {
116
- const config = yield* AdhereConfig;
117
- const cache = yield* AuditCache;
118
- const files = yield* (yield* SourceWalker).files;
149
+ > =>
150
+ Effect.gen(function* () {
151
+ const config = yield* AdhereConfig;
152
+ const cache = yield* AuditCache;
153
+ const files = yield* (yield* SourceWalker).files;
119
154
 
120
- const configuredRules = (file: string) =>
121
- config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
155
+ const configuredRules = (file: string) =>
156
+ config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
122
157
 
123
- const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
124
- const rules = configuredRules(file.path);
125
- // Too long for Jev's context: skipped and counted, rather than refused mid-run.
126
- if (!fits(file.lines, rules)) {
158
+ const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
159
+ const rules = configuredRules(file.path);
160
+ // Too long for Jev's context: skipped and counted, rather than refused mid-run.
161
+ if (!fits(file.lines, rules)) {
162
+ return {
163
+ file,
164
+ skipped: true,
165
+ hash: "",
166
+ prepared: {},
167
+ kept: {},
168
+ pending: {},
169
+ deferred: 0,
170
+ } satisfies FilePlan;
171
+ }
172
+ const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
173
+ Object.entries(rules),
174
+ ([id, rule]) =>
175
+ Effect.map(
176
+ fingerprintOf(config.model, rule),
177
+ (fingerprint) =>
178
+ [id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
179
+ ),
180
+ ).pipe(Effect.map(Record.fromEntries));
181
+ const hash = yield* sha256(file.lines.join("\n"));
182
+ const entry = yield* cache.get(file.path);
183
+ const remembered = entry?.hash === hash ? entry.judgments : {};
184
+ const kept = Record.filter(
185
+ remembered,
186
+ (judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
187
+ );
188
+ const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
127
189
  return {
128
190
  file,
129
- skipped: true,
130
- hash: "",
131
- prepared: {},
132
- kept: {},
133
- pending: {},
191
+ skipped: false,
192
+ hash,
193
+ prepared,
194
+ kept,
195
+ pending,
196
+ deferred: 0,
134
197
  } satisfies FilePlan;
135
- }
136
- const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
137
- Object.entries(rules),
138
- ([id, rule]) =>
139
- Effect.map(
140
- fingerprintOf(config.model, rule),
141
- (fingerprint) =>
142
- [id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
143
- ),
144
- ).pipe(Effect.map(Record.fromEntries));
145
- const hash = yield* sha256(file.lines.join("\n"));
146
- const entry = yield* cache.get(file.path);
147
- const remembered = entry?.hash === hash ? entry.judgments : {};
148
- const kept = Record.filter(
149
- remembered,
150
- (judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
151
- );
152
- const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
153
- return { file, skipped: false, hash, prepared, kept, pending } satisfies FilePlan;
154
- });
198
+ });
155
199
 
156
- const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
157
- const planned: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
158
- concurrency: 8,
200
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
201
+ const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
202
+ concurrency: 8,
203
+ });
204
+ const planned =
205
+ options.limit === undefined ? everything : withinLimit(everything, options.limit);
206
+ const judged = planned.filter((plan) => !plan.skipped);
207
+ const total = (count: (plan: FilePlan) => number) =>
208
+ judged.reduce((sum, plan) => sum + count(plan), 0);
209
+ return {
210
+ files: planned,
211
+ rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
212
+ checks: total((plan) => sizeOf(plan.prepared)),
213
+ cached: total((plan) => sizeOf(plan.kept)),
214
+ skipped: planned.length - judged.length,
215
+ deferred: total((plan) => plan.deferred),
216
+ requests: total((plan) =>
217
+ isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
218
+ ),
219
+ };
159
220
  });
160
- const judged = planned.filter((plan) => !plan.skipped);
161
- const total = (count: (plan: FilePlan) => number) =>
162
- judged.reduce((sum, plan) => sum + count(plan), 0);
163
- return {
164
- files: planned,
165
- rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
166
- checks: total((plan) => sizeOf(plan.prepared)),
167
- cached: total((plan) => sizeOf(plan.kept)),
168
- skipped: planned.length - judged.length,
169
- requests: total((plan) =>
170
- isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
171
- ),
172
- };
173
- });
174
221
 
175
222
  /**
176
223
  * Judges what the plan left pending and locates the findings, a file at a
@@ -191,6 +238,7 @@ export const executeAudit = (
191
238
  prepared,
192
239
  kept,
193
240
  pending,
241
+ deferred,
194
242
  }: FilePlan) {
195
243
  const probabilities = isEmpty(pending)
196
244
  ? {}
@@ -243,7 +291,7 @@ export const executeAudit = (
243
291
  })
244
292
  .sort((a, b) => a.line - b.line);
245
293
  return {
246
- result: fileResult(cached ? "cached" : "judged", findings),
294
+ result: fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings),
247
295
  requests:
248
296
  (isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
249
297
  (isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
@@ -274,6 +322,7 @@ export const executeAudit = (
274
322
  judged: count("judged"),
275
323
  cached: count("cached"),
276
324
  skipped: count("skipped"),
325
+ waiting: count("waiting"),
277
326
  findings: results.flatMap((result) => result.findings),
278
327
  };
279
328
  });
@@ -283,6 +332,6 @@ export const runAudit: Effect.Effect<
283
332
  AuditResult,
284
333
  WalkUnavailable | JevUnavailable,
285
334
  AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
286
- > = Effect.flatMap(planAudit, (plan) => executeAudit(plan));
335
+ > = Effect.flatMap(planAudit(), (plan) => executeAudit(plan));
287
336
 
288
337
  export { type RenderOptions, render } from "#workflows/format.ts";
@@ -124,6 +124,7 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
124
124
  `${result.judged} judged`,
125
125
  `${result.cached} cached`,
126
126
  ...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
127
+ ...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
127
128
  ];
128
129
  return [
129
130
  [span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
@@ -153,18 +154,56 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
153
154
  * between them and how many the cache answers, then what judging the rest
154
155
  * takes.
155
156
  */
156
- export const describePlan = (plan: AuditPlan): ReadonlyArray<string> => {
157
+ /** What a run was asked to keep to, for the plan to say. */
158
+ export interface RunLimits {
159
+ readonly filter?: ReadonlyArray<string>;
160
+ readonly rpm?: number;
161
+ }
162
+
163
+ /** About how long `requests` take at `rpm`: seconds under a minute, minutes past it. */
164
+ const durationAt = (requests: number, rpm: number): string => {
165
+ const seconds = Math.ceil((requests / rpm) * 60);
166
+ return seconds < 60
167
+ ? counted(seconds, "second", "seconds")
168
+ : counted(Math.round(seconds / 60), "minute", "minutes");
169
+ };
170
+
171
+ export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyArray<string> => {
157
172
  const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
158
173
  const skipped =
159
174
  plan.skipped > 0 ? `, ${counted(plan.skipped, "file", "files")} too long to judge` : "";
160
- const found = `${counted(plan.files.length, "file", "files")} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
175
+ const matching = (limits.filter?.length ?? 0) > 0 ? " matching the filter" : "";
176
+ const found = `${counted(plan.files.length, "file", "files")}${matching} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
177
+ const pending = plan.checks - plan.cached;
178
+ const waiting =
179
+ plan.deferred > 0
180
+ ? ` The other ${plan.deferred} ${plan.deferred === 1 ? "waits" : "wait"} for a later run.`
181
+ : "";
161
182
  if (plan.requests === 0) {
162
- return [found, plan.checks === 0 ? "Nothing to judge." : "The cache answers every check."];
183
+ const none =
184
+ plan.deferred > 0
185
+ ? plan.deferred === 1
186
+ ? "The limit leaves the 1 unjudged check for a later run."
187
+ : `The limit leaves all ${plan.deferred} unjudged checks for a later run.`
188
+ : plan.checks === 0
189
+ ? "Nothing to judge."
190
+ : "The cache answers every check.";
191
+ return [found, none];
163
192
  }
164
- const rest = plan.cached > 0 ? `the other ${plan.checks - plan.cached}` : "them";
193
+ const judging = pending - plan.deferred;
194
+ const rest =
195
+ plan.deferred > 0
196
+ ? `${judging} of ${plan.cached > 0 ? `the other ${pending}` : "them"}`
197
+ : plan.cached > 0
198
+ ? `the other ${pending}`
199
+ : "them";
200
+ const pace =
201
+ limits.rpm === undefined
202
+ ? ""
203
+ : ` At ${limits.rpm} a minute, they take about ${durationAt(plan.requests, limits.rpm)}.`;
165
204
  return [
166
205
  found,
167
- `Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.`,
206
+ `Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.${pace}${waiting}`,
168
207
  ];
169
208
  };
170
209