@drkmttr/adhere 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,47 @@
1
1
  import { isNote, withoutComments } from "#comments.ts";
2
- import { type Examples, examplesOf, type Level, type Rule, type RuleId } from "#config.ts";
2
+ import {
3
+ type Examples,
4
+ examplesOf,
5
+ type Level,
6
+ type ReadState,
7
+ type Rule,
8
+ type RuleId,
9
+ type Rules,
10
+ } from "#config.ts";
3
11
  import { type Excerpt, type Range } from "#excerpt.ts";
4
- import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
12
+ import { type ScannedFile, WalkUnavailable } from "#models/Audit.ts";
5
13
  import { AdhereConfig } from "#services/AdhereConfig.ts";
6
14
  import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
7
15
  import {
16
+ describeState,
8
17
  fits,
9
18
  Jev,
19
+ type JevBlocked,
20
+ type JevOverflow,
10
21
  JevUnavailable,
11
22
  type Judged,
12
23
  judgeQuestion,
24
+ judgeBody,
13
25
  judgeLoad,
14
26
  judgeRequests,
15
27
  linterQuestion,
16
28
  type Located,
29
+ locateBody,
17
30
  locateRequests,
18
31
  matcherQuestions,
19
32
  questionRoom,
20
33
  tokensOf,
34
+ logRequestPayload,
21
35
  } from "#services/Jev.ts";
22
- import { SourceWalker } from "#services/SourceWalker.ts";
36
+ import { type SourceReadRequest, SourceReads } from "#services/SourceReads.ts";
37
+ import { type SourceFile, SourceWalker } from "#services/SourceWalker.ts";
23
38
  import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
24
39
  import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
25
40
  import { priceOf } from "#pricing.ts";
26
41
  import { applicableRules, judgesFile, shownId } from "#rules.ts";
27
- import { NO_SUPPRESSIONS, suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
28
- import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
42
+ import { suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
43
+ import { workspacePackagesIn } from "#workspaces.ts";
44
+ import { type Crypto, Duration, Effect, Option, Record, Result, Semaphore, Stream } from "effect";
29
45
 
30
46
  export interface Finding {
31
47
  readonly rule: RuleId;
@@ -65,7 +81,7 @@ export interface AuditResult {
65
81
  readonly judged: number;
66
82
  readonly cached: number;
67
83
  readonly skipped: number;
68
- /** Files whose checks `--limit` left for a later run, none judged in this one. */
84
+ /** Files whose judge or cached-locate requests `--limit` left for a later run. */
69
85
  readonly waiting: number;
70
86
  /** Files the firewall in front of Jev's API refused, with the ID to report each by. */
71
87
  readonly blocked: ReadonlyArray<Blocked>;
@@ -92,6 +108,7 @@ interface PreparedRule {
92
108
  readonly rule: Rule;
93
109
  readonly fingerprint: string;
94
110
  readonly threshold: number;
111
+
95
112
  /** The preset the rule came from, when it is not the project's own. */
96
113
  readonly preset?: string;
97
114
  }
@@ -113,23 +130,14 @@ const LAYOUT = "sections";
113
130
  * matcher re-judges the rule, and a question asked differently re-judges
114
131
  * every rule. So does an edited `appendState`, by its source; what the hook
115
132
  * reads outside the file is not known until it runs, so a change there does
116
- * not. A rule that reads the workspace's packages depends on them too, so a
117
- * package added, renamed, or moved re-judges that rule and no other.
133
+ * not. Shared read context and related files do not invalidate judgments.
118
134
  */
119
- const fingerprintOf = (
120
- model: string,
121
- rule: Rule,
122
- workspacePackages: Readonly<Record<string, string>> = {},
123
- ) => {
135
+ const fingerprintOf = (model: string, rule: Rule) => {
124
136
  const matchers = matcherQuestions("", rule).map(([, question]) => question);
125
137
  const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
126
138
  const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
127
- const packages =
128
- rule.reads?.includes("workspacePackages") === true
129
- ? `\u0000${JSON.stringify(workspacePackages)}`
130
- : "";
131
139
  return sha256(
132
- `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${packages}`,
140
+ `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
133
141
  );
134
142
  };
135
143
 
@@ -146,7 +154,7 @@ const excerptOf = (lines: ReadonlyArray<string>, { first, last }: Range): Excerp
146
154
  const MATCHER_THRESHOLD = 0.5;
147
155
 
148
156
  /** Whether a judgment's matchers let it stand: a yes to every `appliesTo`, and to no `excludeIf`. */
149
- const inScope = (judgment: Judgment): boolean =>
157
+ export const inScope = (judgment: Pick<Judgment, "appliesTo" | "excludeIf">): boolean =>
150
158
  (judgment.appliesTo ?? []).every((score) => score > MATCHER_THRESHOLD) &&
151
159
  !(judgment.excludeIf ?? []).some((score) => score > MATCHER_THRESHOLD);
152
160
 
@@ -165,19 +173,19 @@ const scopeMisses = (rule: Rule, judgment: Judgment): string =>
165
173
 
166
174
  /** One file before anything is sent: its rules, and which of them the cache answers. */
167
175
  export interface FilePlan {
168
- /** The file as Jev reads it: its comments taken out, unless the config keeps them. */
169
- readonly file: ScannedFile;
170
- /** The file's lines as written, which the report's excerpts show. */
171
- readonly original: ReadonlyArray<string>;
172
- /** Too long for Jev: counted, not judged. */
176
+ /** Only the path and classification; contents are loaded in bounded passes, never kept here. */
177
+ readonly file: SourceFile;
178
+ /** Selected work is known to overflow the cheap budget; deferred files may be unbudgeted. */
173
179
  readonly skipped: boolean;
174
180
  readonly hash: string;
181
+ /** Only this file's imported workspace packages; native reads stay local to send time. */
182
+ readonly readState: ReadState;
175
183
  readonly prepared: Readonly<Record<RuleId, PreparedRule>>;
176
184
  /** Cached judgments still valid for the file's content and the rule's text. */
177
185
  readonly kept: Readonly<Record<RuleId, Judgment>>;
178
186
  readonly pending: Readonly<Record<RuleId, PreparedRule>>;
179
- /** Pending checks `--limit` leaves for a later run. */
180
- readonly deferred: number;
187
+ /** The file's entire request workload, including cached locating, waits for a later run. */
188
+ readonly deferred: boolean;
181
189
  /** Pending rules whose linter check question rides along on this file, each with its tally's key. */
182
190
  readonly sampled: Readonly<Record<RuleId, string>>;
183
191
  /** What the file's `adhere-ignore` comments suppress. */
@@ -186,28 +194,39 @@ export interface FilePlan {
186
194
 
187
195
  /** A run worked out from the files, the rules, and the cache, before any request. */
188
196
  export interface AuditPlan {
197
+ readonly model: string;
189
198
  readonly files: ReadonlyArray<FilePlan>;
199
+ /** The normalization used for content keys, also used when reloading a selected file. */
200
+ readonly includeComments?: boolean;
201
+ /** Explicit --limit 0 intent: no native resolution, judging, or locating, even for cached flags. */
202
+ readonly planOnly?: boolean;
203
+ /** Requested native context, not its results. Only execution discovers and uses SourceReads. */
204
+ readonly nativeReads?: SourceReadRequest;
190
205
  /**
191
206
  * The content hashes of files read that no rule in the run judges, such as
192
207
  * tests when no rule says `tests`: a prune keeps what other runs know of them.
193
208
  */
194
209
  readonly unjudged: ReadonlyArray<string>;
195
- /** Rules that apply to at least one file that is judged. */
210
+ /** Rules applying to files not known to be skipped, including unbudgeted deferred files. */
196
211
  readonly rules: number;
197
- /** Every pair of a judged file and a rule that applies to it. */
212
+ /** File/rule pairs not known to be skipped; deferred work stays counted until budgeted. */
198
213
  readonly checks: number;
199
214
  /** Checks a cached judgment answers. */
200
215
  readonly cached: number;
201
- /** Files too long to judge. */
216
+ /** Files whose selected work is known to exceed the cheap budget. */
202
217
  readonly skipped: number;
203
- /** Checks `--limit` leaves for a later run. */
218
+ /** Files whose judge or cached-locate requests `--limit` leaves for a later run. */
204
219
  readonly deferred: number;
205
- /** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
220
+ /** Judge requests estimated from code and cheap context; native reads may split or skip at send time. */
206
221
  readonly requests: number;
207
- /** About how many input tokens those requests carry, which Jev charges for. */
222
+ /** Cheap-context request estimate for locating known cached flags; omitted means zero. */
223
+ readonly cachedLocateRequests?: number;
224
+ /** Estimated judge input tokens, excluding native reads and locating findings. */
208
225
  readonly tokens: number;
209
226
  /** The model's price per million input tokens, in dollars, when adhere knows it. */
210
227
  readonly price?: number;
228
+ /** The model a config's provider asks in place of Jev, which the plan names instead. */
229
+ readonly provider?: string;
211
230
  /** The run's threshold, which a rule's own, or an override's, replaces for that rule. */
212
231
  readonly threshold: number;
213
232
  /** Below this, a finding's sufficiency adds a warning to it. */
@@ -224,9 +243,62 @@ const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(som
224
243
 
225
244
  const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
226
245
 
246
+ /** Flagged judgments whose section or sufficiency still needs a locate request. */
247
+ const unlocated = (
248
+ prepared: Readonly<Record<RuleId, PreparedRule>>,
249
+ judged: Readonly<Record<RuleId, Judgment>>,
250
+ ) =>
251
+ Record.filter(prepared, (k, id) => {
252
+ const judgment = judged[id];
253
+ return (
254
+ judgment !== undefined &&
255
+ judgment.probability > k.threshold &&
256
+ inScope(judgment) &&
257
+ (judgment.section === undefined || judgment.sufficiency === undefined)
258
+ );
259
+ });
260
+
261
+ const needsRequests = (plan: FilePlan): boolean =>
262
+ !plan.skipped &&
263
+ !plan.deferred &&
264
+ (!isEmpty(plan.pending) || !isEmpty(unlocated(plan.prepared, plan.kept)));
265
+
266
+ /** Located findings survive skipped new work; suppression is applied by the file workflow. */
267
+ const findingsOf = (
268
+ { file, prepared }: FilePlan,
269
+ judged: Readonly<Record<RuleId, Judgment>>,
270
+ original: ReadonlyArray<string>,
271
+ ): ReadonlyArray<Finding> =>
272
+ Object.entries(judged)
273
+ .flatMap(([id, judgment]): ReadonlyArray<Finding> => {
274
+ const k = prepared[id];
275
+ return k !== undefined &&
276
+ judgment.section !== undefined &&
277
+ judgment.probability > k.threshold &&
278
+ inScope(judgment)
279
+ ? [
280
+ {
281
+ rule: id,
282
+ ...(k.preset === undefined ? {} : { preset: k.preset }),
283
+ description: k.rule.description,
284
+ examples: examplesOf(k.rule),
285
+ file: file.path,
286
+ section: judgment.section,
287
+ ...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
288
+ excerpt: excerptOf(original, judgment.section),
289
+ ...(judgment.sufficiency === undefined ? {} : { context: judgment.sufficiency }),
290
+ probability: judgment.probability,
291
+ threshold: k.threshold,
292
+ level: k.rule.level ?? "error",
293
+ },
294
+ ]
295
+ : [];
296
+ })
297
+ .sort((a, b) => (a.lines ?? a.section).first - (b.lines ?? b.section).first);
298
+
227
299
  /** A refusal that names the file it stopped at. Files finished before it are already cached. */
228
300
  const inFile =
229
- (file: ScannedFile) =>
301
+ (file: SourceFile) =>
230
302
  <A, E, R>(self: Effect.Effect<A, E, R>) =>
231
303
  Effect.mapError(self, (problem) =>
232
304
  problem instanceof JevUnavailable
@@ -236,29 +308,68 @@ const inFile =
236
308
  : problem,
237
309
  );
238
310
 
239
- /**
240
- * The plan with at most `limit` checks left to judge, taken in path order.
241
- * Past the limit a file's other checks wait for a later run; judgments are
242
- * cached, so a rerun picks up where this one stopped.
243
- */
244
- const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyArray<FilePlan> => {
245
- let left = limit;
246
- return planned.map((plan) => {
247
- const ids = Object.keys(plan.pending);
248
- const taken = new Set(ids.slice(0, left));
249
- left -= taken.size;
250
- return taken.size === ids.length
251
- ? plan
252
- : {
253
- ...plan,
254
- pending: Record.filter(plan.pending, (_, id) => taken.has(id)),
255
- deferred: ids.length - taken.size,
256
- };
257
- });
311
+ /** Preserve line numbers while blanking directives, and comments other than @adhere notes. */
312
+ const normalized = (original: ReadonlyArray<string>, includeComments: boolean) => {
313
+ const { lines, suppressions } = suppressionsOf(original);
314
+ return {
315
+ lines: includeComments ? lines : withoutComments(lines, isNote),
316
+ suppressions,
317
+ };
258
318
  };
259
319
 
320
+ interface LoadedFile {
321
+ readonly file: ScannedFile;
322
+ readonly original: ReadonlyArray<string>;
323
+ }
324
+
325
+ /** Ignore directives are absent from the code key, but their parsed policy still belongs to the plan. */
326
+ const sameSuppressions = (left: Suppressions, right: Suppressions): boolean => {
327
+ if (left === right) return true;
328
+ const sameRules = (a: ReadonlySet<string>, b: ReadonlySet<string>) =>
329
+ a.size === b.size && [...a].every((rule) => b.has(rule));
330
+ return (
331
+ sameRules(left.file, right.file) &&
332
+ left.statements.length === right.statements.length &&
333
+ left.statements.every((statement, index) => {
334
+ const other = right.statements[index]!;
335
+ return (
336
+ statement.first === other.first &&
337
+ statement.last === other.last &&
338
+ sameRules(statement.rules, other.rules)
339
+ );
340
+ })
341
+ );
342
+ };
343
+
344
+ /** Validate code and ignore policy before sending a request or showing a cached excerpt. */
345
+ const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeComments: boolean) {
346
+ const original = yield* (yield* SourceWalker).read(plan.file);
347
+ const { lines, suppressions } = normalized(original.lines, includeComments);
348
+ if (
349
+ (yield* sha256(lines.join("\n"))) !== plan.hash ||
350
+ !sameSuppressions(plan.suppressions, suppressions)
351
+ ) {
352
+ return yield* WalkUnavailable.make({
353
+ message: `${plan.file.path}: source changed since planning. Rerun adhere lint to create a fresh plan; changed contents were not sent or cached under the old content key. Files already completed remain cached.`,
354
+ });
355
+ }
356
+ return { file: { ...plan.file, lines }, original: original.lines } satisfies LoadedFile;
357
+ });
358
+
359
+ /** A cached finding needs the source again even when no request will be sent. */
360
+ const hasLocatedFindings = (plan: FilePlan): boolean =>
361
+ Object.entries(plan.kept).some(([id, judgment]) => {
362
+ const prepared = plan.prepared[id];
363
+ return (
364
+ prepared !== undefined &&
365
+ judgment.section !== undefined &&
366
+ judgment.probability > prepared.threshold &&
367
+ inScope(judgment)
368
+ );
369
+ });
370
+
260
371
  export interface PlanOptions {
261
- /** Judge at most this many checks; the rest wait for a later run. */
372
+ /** Select at most this many files needing judge or cached-locate requests; zero plans only. */
262
373
  readonly limit?: number | undefined;
263
374
  }
264
375
 
@@ -272,72 +383,71 @@ export const planAudit = (
272
383
  Effect.gen(function* () {
273
384
  const config = yield* AdhereConfig;
274
385
  const cache = yield* AuditCache;
275
- const walked = yield* (yield* SourceWalker).files;
276
- // Each file as Jev reads it, each line keeping its number: its adhere-ignore comments blanked,
277
- // and every other comment too but the @adhere notes, unless the config keeps comments. A
278
- // comment changes nothing a file does, so it should not change the report.
279
- const suppressed = new Map<string, Suppressions>();
280
- const originals = new Map<string, ReadonlyArray<string>>();
281
- const files = walked.map((file) => {
282
- const { lines: directed, suppressions } = suppressionsOf(file.lines);
283
- if (suppressions !== NO_SUPPRESSIONS) suppressed.set(file.path, suppressions);
284
- const lines = config.includeComments === true ? directed : withoutComments(directed, isNote);
285
- if (lines === file.lines) return file;
286
- originals.set(file.path, file.lines);
287
- return { ...file, lines };
288
- });
289
- const suppressionsIn = (file: ScannedFile) => suppressed.get(file.path) ?? NO_SUPPRESSIONS;
290
- const originalOf = (file: ScannedFile) => originals.get(file.path) ?? file.lines;
386
+ const workspacePackages = config.reads.includes("workspacePackages")
387
+ ? (config.workspacePackages ?? {})
388
+ : undefined;
389
+ const nativeReads: SourceReadRequest = {
390
+ symbols: config.reads.includes("symbols"),
391
+ references: config.reads.includes("references"),
392
+ includeComments: config.includeComments === true,
393
+ maxSymbolLength: config.maxSymbolLength,
394
+ maxSymbolDocumentationLength: config.maxSymbolDocumentationLength,
395
+ };
396
+ const walker = yield* SourceWalker;
397
+ const files = yield* walker.files;
398
+
399
+ // Each preset's rule, with the preset it came from, for its findings to name.
400
+ const presetOfRule = new Map(
401
+ (config.scopedRules ?? []).flatMap((entry) =>
402
+ entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
403
+ ),
404
+ );
291
405
 
292
406
  // The rules that judge a file: those scoped to it, of which only the rules that say `tests`
293
407
  // judge a test, and none that an adhere-ignore-file comment names.
294
- const configuredRules = (file: ScannedFile) =>
408
+ const configuredRules = (file: SourceFile, suppressions: Suppressions) =>
295
409
  Record.filter(
296
410
  config.scopedRules === undefined
297
411
  ? config.rules
298
412
  : applicableRules(file.path, config.scopedRules),
299
413
  (rule, id) =>
300
414
  judgesFile(rule, file.test === true) &&
301
- !suppressionsIn(file).file.has(shownId({ id, preset: presetOfRule.get(rule) })),
415
+ !suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) })),
302
416
  );
303
417
 
304
- const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
305
- const rules = configuredRules(file);
306
- // Hashed even when skipped, so a prune keeps what other runs know of its content.
307
- const hash = yield* sha256(file.lines.join("\n"));
308
- // Too long for Jev's context: skipped and counted, rather than refused mid-run.
309
- if (!fits(file.lines, rules)) {
310
- yield* Effect.logTrace(`plan ${file.path}: too long for Jev's context, skipped`);
311
- return {
312
- file,
313
- skipped: true,
314
- hash,
315
- prepared: {},
316
- kept: {},
317
- pending: {},
318
- deferred: 0,
319
- sampled: {},
320
- suppressions: suppressionsIn(file),
321
- original: originalOf(file),
322
- } satisfies FilePlan;
323
- }
324
- const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
325
- Object.entries(rules),
326
- ([id, rule]) =>
327
- Effect.map(fingerprintOf(config.model, rule, config.workspacePackages), (fingerprint) => {
418
+ // Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
419
+ const preparedRules = new Map<Rule, PreparedRule>(
420
+ yield* Effect.forEach(
421
+ new Set(config.scopedRules?.map(({ rule }) => rule) ?? Object.values(config.rules)),
422
+ (rule) =>
423
+ Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
328
424
  const preset = presetOfRule.get(rule);
329
425
  return [
330
- id,
331
- {
426
+ rule,
427
+ Object.freeze({
332
428
  rule,
333
429
  fingerprint,
334
430
  threshold: rule.threshold ?? config.threshold,
335
431
  ...(preset === undefined ? {} : { preset }),
336
- },
432
+ }),
337
433
  ] as const;
338
434
  }),
339
- ).pipe(Effect.map(Record.fromEntries));
340
- const answers = yield* cache.get(hash, file.path);
435
+ ),
436
+ );
437
+
438
+ const planFile = Effect.fn("audit.plan")(function* (file: SourceFile) {
439
+ const original = yield* walker.read(file);
440
+ const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
441
+ const rules = configuredRules(file, suppressions);
442
+ const code = lines.join("\n");
443
+ const readState: ReadState =
444
+ workspacePackages === undefined
445
+ ? {}
446
+ : { workspacePackages: workspacePackagesIn(code, workspacePackages) };
447
+ // Hashed even when unjudged or skipped, so a prune keeps what other runs know of its content.
448
+ const hash = yield* sha256(code);
449
+ const prepared = Record.map(rules, (rule) => preparedRules.get(rule)!);
450
+ const answers = isEmpty(prepared) ? {} : yield* cache.get(hash, file.path);
341
451
  const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
342
452
  const answer = answers[fingerprint];
343
453
  return answer === undefined ? Result.failVoid : Result.succeed({ fingerprint, ...answer });
@@ -347,25 +457,22 @@ export const planAudit = (
347
457
  `plan ${file.path}: ${sizeOf(prepared)} rules, ${sizeOf(kept)} cached, ${sizeOf(pending)} to judge`,
348
458
  );
349
459
  return {
350
- file,
351
- skipped: false,
352
- hash,
353
- prepared,
354
- kept,
355
- pending,
356
- deferred: 0,
357
- sampled: {},
358
- suppressions: suppressionsIn(file),
359
- original: originalOf(file),
360
- } satisfies FilePlan;
460
+ lines,
461
+ plan: {
462
+ file,
463
+ skipped: false,
464
+ hash,
465
+ readState,
466
+ prepared,
467
+ kept,
468
+ pending,
469
+ deferred: false,
470
+ sampled: {},
471
+ suppressions,
472
+ } satisfies FilePlan,
473
+ };
361
474
  });
362
475
 
363
- // Each preset's rule, with the preset it came from, for its findings to name.
364
- const presetOfRule = new Map(
365
- (config.scopedRules ?? []).flatMap((entry) =>
366
- entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
367
- ),
368
- );
369
476
  // Preset rules are not the project's to change, so the linter check leaves them out.
370
477
  const presetRules = new Set(
371
478
  (config.scopedRules ?? []).flatMap((entry) =>
@@ -373,33 +480,109 @@ export const planAudit = (
373
480
  ),
374
481
  );
375
482
 
483
+ // Ordered prefetch keeps only a bounded number of normalized files alive. Select whole
484
+ // request-bearing files before budgeting them; cheap overflow leaves the slot for the next path.
485
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
486
+ const limited: Array<FilePlan> = [];
487
+ const unjudged: Array<string> = [];
488
+ const rooms = new Map<FilePlan, number>();
489
+ // Undefined marks a full tally, avoiding repeated lookups without retaining its file map.
490
+ const needs = new Map<
491
+ Rule,
492
+ { readonly key: string; readonly tallied: Tally["files"] } | undefined
493
+ >();
494
+ const baseLoads = new Map<
495
+ SourceFile,
496
+ { readonly requests: number; readonly tokens: number; readonly locating: number }
497
+ >();
498
+ const status = yield* Status;
499
+ let left = options.limit ?? Infinity;
500
+ let done = 0;
501
+ yield* clearingStatus(
502
+ Stream.fromIterable(sorted).pipe(
503
+ Stream.mapEffect(planFile, { concurrency: 8 }),
504
+ Stream.runForEach(({ plan, lines }) =>
505
+ Effect.gen(function* () {
506
+ if (isEmpty(plan.prepared)) {
507
+ unjudged.push(plan.hash);
508
+ } else {
509
+ const pending = plan.pending;
510
+ const flagged = unlocated(plan.prepared, plan.kept);
511
+ const checking = { ...pending, ...flagged };
512
+ if (isEmpty(checking)) {
513
+ limited.push(plan);
514
+ } else if (left <= 0) {
515
+ limited.push({ ...plan, pending: {}, deferred: true });
516
+ } else if (!fits(lines, rulesOf(checking), plan.readState)) {
517
+ yield* logRequestPayload(
518
+ `${plan.file.path}: too long preflight payload (planning)`,
519
+ () => ({
520
+ judge: judgeBody(config.model, lines, rulesOf(checking), [], plan.readState),
521
+ locate: locateBody(config.model, lines, rulesOf(checking), plan.readState),
522
+ }),
523
+ );
524
+ limited.push({ ...plan, skipped: true, pending: {} });
525
+ } else {
526
+ left -= 1;
527
+ const selected = plan;
528
+ limited.push(selected);
529
+ if (!isEmpty(checking)) {
530
+ // Exact base estimates while the code is already loaded; retain only scalars.
531
+ const load = isEmpty(pending)
532
+ ? { requests: 0, tokens: 0 }
533
+ : judgeLoad(lines, rulesOf(pending), [], plan.readState);
534
+ baseLoads.set(selected.file, {
535
+ ...load,
536
+ locating: isEmpty(flagged)
537
+ ? 0
538
+ : locateRequests(lines, rulesOf(flagged), plan.readState),
539
+ });
540
+ }
541
+ let candidate = false;
542
+ for (const { rule } of Object.values(pending)) {
543
+ if (presetRules.has(rule)) continue;
544
+ if (!needs.has(rule)) {
545
+ const key = yield* tallyKeyOf(config.model, rule);
546
+ const tallied = (yield* cache.tally(key))?.files ?? {};
547
+ needs.set(
548
+ rule,
549
+ Object.keys(tallied).length >= SAMPLE ? undefined : { key, tallied },
550
+ );
551
+ }
552
+ const need = needs.get(rule);
553
+ if (need !== undefined && need.tallied[selected.file.path] === undefined)
554
+ candidate = true;
555
+ }
556
+ if (candidate) rooms.set(selected, questionRoom(lines, plan.readState));
557
+ }
558
+ }
559
+ done += 1;
560
+ yield* status.show(
561
+ `Planning: ${counted(done)} of ${countOf(sorted.length, "file", "files")}`,
562
+ );
563
+ }),
564
+ ),
565
+ ),
566
+ );
567
+
568
+ // Source text has left the pipeline; sampling needs only candidate metadata and room.
569
+
376
570
  /**
377
571
  * The linter check's samples: for each project rule its tally still needs
378
572
  * answers for, files this run judges it on, spread evenly over them in path
379
573
  * order, skipping files the tally has and files its question would not fit.
380
574
  */
381
- const withSamples = Effect.fn("audit.samples")(function* (plans: ReadonlyArray<FilePlan>) {
382
- const needs = new Map<Rule, { readonly key: string; readonly tallied: Tally["files"] }>();
383
- for (const plan of plans) {
384
- for (const { rule } of Object.values(plan.pending)) {
385
- if (presetRules.has(rule) || needs.has(rule)) continue;
386
- const key = yield* tallyKeyOf(config.model, rule);
387
- needs.set(rule, { key, tallied: (yield* cache.tally(key))?.files ?? {} });
388
- }
389
- }
390
- for (const [rule, { tallied }] of needs) {
391
- if (Object.keys(tallied).length >= SAMPLE) needs.delete(rule);
392
- }
393
- if (needs.size === 0) return plans;
575
+ const withSamples = (plans: ReadonlyArray<FilePlan>) => {
576
+ if (rooms.size === 0) return plans;
394
577
 
395
578
  const candidates = new Map<Rule, Array<{ readonly plan: FilePlan; readonly id: RuleId }>>();
396
579
  const tokens = new Map<Rule, number>();
397
580
  for (const plan of plans) {
398
- let room: number | undefined;
581
+ const room = rooms.get(plan);
582
+ if (room === undefined) continue;
399
583
  for (const [id, { rule }] of Object.entries(plan.pending)) {
400
584
  const need = needs.get(rule);
401
585
  if (need === undefined || need.tallied[plan.file.path] !== undefined) continue;
402
- room ??= questionRoom(plan.file.lines);
403
586
  const size = tokens.get(rule) ?? tokensOf(linterQuestion(rule));
404
587
  tokens.set(rule, size);
405
588
  if (size > room) continue;
@@ -425,58 +608,49 @@ export const planAudit = (
425
608
  const some = sampled.get(plan);
426
609
  return some === undefined ? plan : { ...plan, sampled: some };
427
610
  });
428
- });
611
+ };
429
612
 
430
- // A file no rule judges, such as a test when no rule says `tests`, is not part of the run,
431
- // but its content is: another run's rules may judge it.
432
- const sorted = files
433
- .filter((file) => !isEmpty(configuredRules(file)))
434
- .sort((a, b) => a.path.localeCompare(b.path));
435
- const unjudged = yield* Effect.forEach(
436
- files.filter((file) => isEmpty(configuredRules(file))),
437
- (file) => sha256(file.lines.join("\n")),
438
- { concurrency: 8 },
439
- );
440
- const status = yield* Status;
441
- let done = 0;
442
- const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
443
- Effect.forEach(
444
- sorted,
445
- (file) =>
446
- planFile(file).pipe(
447
- Effect.tap(() => {
448
- done += 1;
449
- return status.show(
450
- `Planning: ${counted(done)} of ${countOf(sorted.length, "file", "files")}`,
451
- );
452
- }),
453
- ),
454
- { concurrency: 8 },
455
- ),
456
- );
457
- const limited =
458
- options.limit === undefined ? everything : withinLimit(everything, options.limit);
459
- const planned = yield* withSamples(limited);
613
+ const planned = withSamples(limited);
460
614
  const judged = planned.filter((plan) => !plan.skipped);
461
- const loads = judged.map((plan) =>
462
- isEmpty(plan.pending)
463
- ? { requests: 0, tokens: 0 }
464
- : judgeLoad(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
615
+ // Only added sample questions can change the base judge estimate. Recompute those files
616
+ // after global sampling, with the same snapshot validation as execution. Locating is unchanged.
617
+ yield* Effect.forEach(
618
+ judged.filter((plan) => !isEmpty(plan.sampled)),
619
+ (plan) =>
620
+ Effect.gen(function* () {
621
+ const { file } = yield* reloadFile(plan, config.includeComments === true);
622
+ const load = judgeLoad(
623
+ file.lines,
624
+ rulesOf(plan.pending),
625
+ Object.keys(plan.sampled),
626
+ plan.readState,
627
+ );
628
+ baseLoads.set(plan.file, { ...load, locating: baseLoads.get(plan.file)!.locating });
629
+ }),
630
+ { concurrency: 8, discard: true },
465
631
  );
632
+ const loads = [...baseLoads.values()];
466
633
  const price = priceOf(config.model);
467
634
  const total = (count: (plan: FilePlan) => number) =>
468
635
  judged.reduce((sum, plan) => sum + count(plan), 0);
636
+ const cachedLocateRequests = loads.reduce((sum, load) => sum + load.locating, 0);
469
637
  return {
638
+ model: config.model,
470
639
  files: planned,
640
+ ...(config.includeComments === true ? { includeComments: true } : {}),
641
+ ...(options.limit === 0 ? { planOnly: true } : {}),
642
+ ...(cachedLocateRequests > 0 ? { cachedLocateRequests } : {}),
643
+ ...(nativeReads.symbols || nativeReads.references ? { nativeReads } : {}),
471
644
  unjudged,
472
645
  rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
473
646
  checks: total((plan) => sizeOf(plan.prepared)),
474
647
  cached: total((plan) => sizeOf(plan.kept)),
475
648
  skipped: planned.length - judged.length,
476
- deferred: total((plan) => plan.deferred),
649
+ deferred: total((plan) => (plan.deferred ? 1 : 0)),
477
650
  requests: loads.reduce((sum, load) => sum + load.requests, 0),
478
651
  tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
479
652
  ...(price === undefined ? {} : { price }),
653
+ ...(config.provider === undefined ? {} : { provider: config.provider.model }),
480
654
  threshold: config.threshold,
481
655
  sufficiencyThreshold: config.sufficiencyThreshold,
482
656
  };
@@ -484,15 +658,37 @@ export const planAudit = (
484
658
 
485
659
  /**
486
660
  * Judges what the plan left pending and locates the findings, a file at a
487
- * time, telling `progress` as each file finishes.
661
+ * time, telling `progress` as each file finishes. A plan-only run reports
662
+ * cached findings without resolving native context or sending requests.
488
663
  */
489
664
  export const executeAudit = (
490
665
  plan: AuditPlan,
491
666
  progress: (done: FileDone) => Effect.Effect<void> = () => Effect.void,
492
- ): Effect.Effect<AuditResult, JevUnavailable, AuditCache | Jev> =>
667
+ ): Effect.Effect<
668
+ AuditResult,
669
+ WalkUnavailable | JevUnavailable,
670
+ AuditCache | Crypto.Crypto | Jev | SourceWalker
671
+ > =>
493
672
  Effect.gen(function* () {
494
673
  const jev = yield* Jev;
495
674
  const cache = yield* AuditCache;
675
+ const planOnly = plan.planOnly === true;
676
+ const nativeReads =
677
+ plan.nativeReads?.symbols || plan.nativeReads?.references ? plan.nativeReads : undefined;
678
+ const eligible = planOnly ? [] : plan.files.filter(needsRequests);
679
+ const sourceReads =
680
+ nativeReads === undefined || eligible.length === 0
681
+ ? Option.none()
682
+ : yield* Effect.serviceOption(SourceReads);
683
+ if (nativeReads !== undefined && eligible.length > 0) {
684
+ if (Option.isNone(sourceReads)) {
685
+ return yield* WalkUnavailable.make({
686
+ message:
687
+ "symbols/references require the SourceReads service during execution. Run lint through adhere's npm launcher or a checkout with its development dependencies installed, or set reads: [] for code-only audits.",
688
+ });
689
+ }
690
+ yield* sourceReads.value.prepare(eligible.map(({ file }) => file.path));
691
+ }
496
692
  // Files finish together; each tally is read, extended, and written back by one file at a time.
497
693
  const tallying = yield* Semaphore.make(1);
498
694
 
@@ -517,25 +713,106 @@ export const executeAudit = (
517
713
  ),
518
714
  );
519
715
 
520
- /** Judges and locates one file the plan left pending, with the requests that took. */
521
- const judgeFile = Effect.fn("audit.judgeFile")(function* ({
522
- file,
523
- original,
524
- hash,
525
- prepared,
526
- kept,
527
- pending,
528
- deferred,
529
- sampled,
530
- }: FilePlan) {
531
- // What a rule's appendState gets: the file as written, not as Jev reads it.
716
+ /** Judges and locates one file, keeping its full read state local and counting send-time splits. */
717
+ const judgeFile = Effect.fn("audit.judgeFile")(function* (
718
+ filePlan: FilePlan,
719
+ { file, original }: LoadedFile,
720
+ sent: (requests: number) => void,
721
+ ) {
722
+ const { hash, prepared, kept, pending, sampled, readState: cheapState } = filePlan;
723
+ // What native reads and a rule's appendState get: the file as written, not as Jev reads it.
532
724
  const asWritten = { path: file.path, contents: original.join("\n") };
725
+ const checking = { ...pending, ...unlocated(prepared, kept) };
726
+ let readState = cheapState;
727
+ if (!isEmpty(checking)) {
728
+ if (nativeReads !== undefined && Option.isSome(sourceReads)) {
729
+ const native = yield* sourceReads.value
730
+ .read(asWritten, file.lines, nativeReads)
731
+ .pipe(
732
+ Effect.mapError((problem) =>
733
+ WalkUnavailable.make({ message: `${file.path}: ${problem.message}` }),
734
+ ),
735
+ );
736
+ readState = {
737
+ ...cheapState,
738
+ ...Record.filter(
739
+ native,
740
+ (_, name) =>
741
+ (name === "symbols" && nativeReads.symbols) ||
742
+ (name === "references" && nativeReads.references),
743
+ ),
744
+ };
745
+ }
746
+ }
747
+ let referencesOmitted = false;
748
+ const hasHooks = Object.values(checking).some(({ rule }) => rule.appendState !== undefined);
749
+ let referencesForHooks: unknown;
750
+ const omitReferences = (reason: string) =>
751
+ Effect.gen(function* () {
752
+ referencesForHooks = hasHooks ? readState.references : undefined;
753
+ readState = Record.filter(readState, (_, name) => name !== "references");
754
+ referencesOmitted = true;
755
+ yield* Effect.logDebug(`${file.path}: ${reason}; attempting without references`);
756
+ });
757
+ const rulesFor = (entries: FilePlan["prepared"]): Rules => {
758
+ const rules = rulesOf(entries);
759
+ if (!referencesOmitted) return rules;
760
+ return Record.map(rules, (rule) => {
761
+ const appendState = rule.appendState;
762
+ return appendState === undefined
763
+ ? rule
764
+ : {
765
+ ...rule,
766
+ appendState: async (state, file, bun) => {
767
+ const input =
768
+ referencesForHooks === undefined
769
+ ? state
770
+ : describeState({ ...state, references: referencesForHooks });
771
+ return Record.filter(
772
+ await appendState(input, file, bun),
773
+ (_, name) => name !== "references",
774
+ );
775
+ },
776
+ };
777
+ });
778
+ };
779
+ const retryWithoutReferences = <A>(
780
+ work: () => Effect.Effect<A, JevUnavailable | JevOverflow | JevBlocked>,
781
+ ) =>
782
+ Effect.suspend(work).pipe(
783
+ Effect.catchTag("JevOverflow", (problem) =>
784
+ referencesOmitted || (!("references" in readState) && !hasHooks)
785
+ ? Effect.fail(problem)
786
+ : omitReferences("provider context limit").pipe(Effect.andThen(Effect.suspend(work))),
787
+ ),
788
+ );
789
+ if (!isEmpty(checking) && !fits(file.lines, rulesOf(checking), readState)) {
790
+ yield* logRequestPayload(`${file.path}: too long preflight payload (full context)`, () => ({
791
+ judge: judgeBody(plan.model, file.lines, rulesOf(checking), [], readState),
792
+ locate: locateBody(plan.model, file.lines, rulesOf(checking), readState),
793
+ }));
794
+ if ("references" in readState) {
795
+ // Let the provider decide whether the reduced payload fits; do not veto the fallback.
796
+ yield* omitReferences("full context too long for Jev (preflight limit)");
797
+ } else {
798
+ yield* Effect.logDebug(`${file.path}: full context too long for Jev, skipped`);
799
+ return fileResult("skipped", findingsOf(filePlan, kept, original));
800
+ }
801
+ }
802
+ const room = questionRoom(file.lines, readState);
803
+ const sentSamples = Record.filter(sampled, (_, id) => {
804
+ const k = pending[id];
805
+ return k !== undefined && tokensOf(linterQuestion(k.rule)) <= room;
806
+ });
807
+ const sampleIds = Object.keys(sentSamples);
533
808
  const { probabilities, linter, matchers }: Judged = isEmpty(pending)
534
809
  ? { probabilities: {}, linter: {} }
535
- : yield* jev
536
- .judge(file.lines, rulesOf(pending), Object.keys(sampled), asWritten)
537
- .pipe(inFile(file));
538
- if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
810
+ : yield* retryWithoutReferences(() => {
811
+ const rules = rulesFor(pending);
812
+ sent(judgeRequests(file.lines, rules, sampleIds, readState));
813
+ return jev.judge(file.lines, rules, sampleIds, asWritten, readState).pipe(inFile(file));
814
+ });
815
+ if (!isEmpty(linter)) yield* record(file.path, sentSamples, linter);
539
816
  const judged: Record<RuleId, Judgment> = { ...kept };
540
817
  for (const [id, probability] of Object.entries(probabilities)) {
541
818
  const k = pending[id];
@@ -553,15 +830,7 @@ export const executeAudit = (
553
830
  }
554
831
  }
555
832
 
556
- const flagged = Record.filter(prepared, (k, id) => {
557
- const judgment = judged[id];
558
- return (
559
- judgment !== undefined &&
560
- judgment.probability > k.threshold &&
561
- inScope(judgment) &&
562
- (judgment.section === undefined || judgment.sufficiency === undefined)
563
- );
564
- });
833
+ const flagged = unlocated(prepared, judged);
565
834
  // A blocked locate question still leaves the judgments worth caching: a rerun
566
835
  // asks only for the sections again, not for every rule.
567
836
  const {
@@ -572,7 +841,11 @@ export const executeAudit = (
572
841
  readonly blocked: Blocked | undefined;
573
842
  } = isEmpty(flagged)
574
843
  ? { places: {}, blocked: undefined }
575
- : yield* jev.locate(file.lines, rulesOf(flagged), asWritten).pipe(
844
+ : yield* retryWithoutReferences(() => {
845
+ const rules = rulesFor(flagged);
846
+ sent(locateRequests(file.lines, rules, readState));
847
+ return jev.locate(file.lines, rules, asWritten, readState);
848
+ }).pipe(
576
849
  Effect.map((places) => ({ places, blocked: undefined })),
577
850
  Effect.catchTag("JevBlocked", ({ ray }) =>
578
851
  Effect.succeed({ places: {}, blocked: { file: file.path, ray } }),
@@ -587,76 +860,49 @@ export const executeAudit = (
587
860
  const cached = isEmpty(pending) && isEmpty(flagged);
588
861
  if (!cached) yield* cache.put(hash, answersOf(located));
589
862
 
590
- const findings = Object.entries(located)
591
- .flatMap(([id, judgment]): ReadonlyArray<Finding> => {
592
- const k = prepared[id];
593
- return k !== undefined &&
594
- judgment.section !== undefined &&
595
- judgment.probability > k.threshold &&
596
- inScope(judgment)
597
- ? [
598
- {
599
- rule: id,
600
- ...(k.preset === undefined ? {} : { preset: k.preset }),
601
- description: k.rule.description,
602
- examples: examplesOf(k.rule),
603
- file: file.path,
604
- section: judgment.section,
605
- ...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
606
- excerpt: excerptOf(original, judgment.section),
607
- ...(judgment.sufficiency === undefined ? {} : { context: judgment.sufficiency }),
608
- probability: judgment.probability,
609
- threshold: k.threshold,
610
- level: k.rule.level ?? "error",
611
- },
612
- ]
613
- : [];
614
- })
615
- .sort((a, b) => (a.lines ?? a.section).first - (b.lines ?? b.section).first);
616
- return {
617
- result:
618
- blocked === undefined
619
- ? fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings)
620
- : { status: "blocked", findings, blocked },
621
- requests:
622
- (isEmpty(pending)
623
- ? 0
624
- : judgeRequests(file.lines, rulesOf(pending), Object.keys(sampled))) +
625
- (isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
626
- };
863
+ const findings = findingsOf(filePlan, located, original);
864
+ return blocked === undefined
865
+ ? fileResult(cached ? "cached" : "judged", findings)
866
+ : ({ status: "blocked", findings, blocked } satisfies FileResult);
627
867
  });
628
868
 
629
- const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
630
- const judging = plan.skipped
631
- ? Effect.succeed({ result: fileResult("skipped", []), requests: 0 })
632
- : judgeFile(plan).pipe(
633
- // Jev counted more than `fits` estimated. Its count decides: skipped the same way.
634
- Effect.catchTag("JevOverflow", () =>
635
- Effect.succeed({
636
- result: fileResult("skipped", []),
637
- requests: judgeRequests(
638
- plan.file.lines,
639
- rulesOf(plan.pending),
640
- Object.keys(plan.sampled),
641
- ),
642
- }),
643
- ),
644
- // The firewall refuses this file's code every time; the rest of the run goes on.
645
- Effect.catchTag("JevBlocked", ({ ray }) =>
646
- Effect.succeed({
647
- result: {
648
- status: "blocked",
649
- findings: [],
650
- blocked: { file: plan.file.path, ray },
651
- } satisfies FileResult,
652
- requests: judgeRequests(
653
- plan.file.lines,
654
- rulesOf(plan.pending),
655
- Object.keys(plan.sampled),
656
- ),
657
- }),
658
- ),
659
- );
869
+ const auditFile = Effect.fn("audit.file")(function* (filePlan: FilePlan) {
870
+ const requesting = !planOnly && needsRequests(filePlan);
871
+ const loaded =
872
+ requesting || hasLocatedFindings(filePlan)
873
+ ? yield* reloadFile(filePlan, plan.includeComments === true)
874
+ : undefined;
875
+ const keptFindings =
876
+ loaded === undefined ? [] : findingsOf(filePlan, filePlan.kept, loaded.original);
877
+ let sent = 0;
878
+ const judging = (
879
+ !requesting || loaded === undefined
880
+ ? Effect.succeed(
881
+ fileResult(
882
+ filePlan.skipped
883
+ ? "skipped"
884
+ : filePlan.deferred || !isEmpty(filePlan.pending)
885
+ ? "waiting"
886
+ : "cached",
887
+ keptFindings,
888
+ ),
889
+ )
890
+ : judgeFile(filePlan, loaded, (requests) => {
891
+ sent += requests;
892
+ })
893
+ ).pipe(
894
+ // Jev counted more than `fits` estimated. Its count decides: skipped the same way.
895
+ Effect.catchTag("JevOverflow", () => Effect.succeed(fileResult("skipped", keptFindings))),
896
+ // The firewall refuses this file's code every time; the rest of the run goes on.
897
+ Effect.catchTag("JevBlocked", ({ ray }) =>
898
+ Effect.succeed({
899
+ status: "blocked",
900
+ findings: keptFindings,
901
+ blocked: { file: filePlan.file.path, ray },
902
+ } satisfies FileResult),
903
+ ),
904
+ Effect.map((result) => ({ result, requests: sent })),
905
+ );
660
906
  // Everything logged while judging a file, requests included, names the file.
661
907
  // A file that sent nothing, whether cached or too long, is a trace line: a
662
908
  // large repo's cache would otherwise bury the requests.
@@ -666,13 +912,13 @@ export const executeAudit = (
666
912
  `${result.status}: ${requests} ${requests === 1 ? "request" : "requests"}, ${result.findings.length} ${result.findings.length === 1 ? "finding" : "findings"}, ${Math.round(Duration.toMillis(elapsed))} ms`,
667
913
  ),
668
914
  ),
669
- Effect.annotateLogs("file", plan.file.path),
915
+ Effect.annotateLogs("file", filePlan.file.path),
670
916
  );
671
917
  // A finding an adhere-ignore comment covers is left out of the report, and counted.
672
918
  const shown = result.findings.filter(
673
919
  (finding) =>
674
920
  !suppresses(
675
- plan.suppressions,
921
+ filePlan.suppressions,
676
922
  shownId({ id: finding.rule, preset: finding.preset }),
677
923
  finding.lines ?? finding.section,
678
924
  ),
@@ -680,7 +926,7 @@ export const executeAudit = (
680
926
  if (shown.length < result.findings.length) {
681
927
  yield* Effect.logDebug(
682
928
  `${result.findings.length - shown.length} suppressed by adhere-ignore`,
683
- ).pipe(Effect.annotateLogs("file", plan.file.path));
929
+ ).pipe(Effect.annotateLogs("file", filePlan.file.path));
684
930
  }
685
931
  yield* progress({ requests, findings: shown.length });
686
932
  return { ...result, findings: shown, suppressed: result.findings.length - shown.length };