@drkmttr/adhere 0.14.0 → 0.17.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.
Files changed (37) hide show
  1. package/README.md +294 -146
  2. package/bin/adhere.js +19 -4
  3. package/package.json +34 -12
  4. package/presets/effect/config/tests-provide-values-directly/RULE.md +12 -1
  5. package/presets/effect/data/brand-meaningful-primitives/RULE.md +14 -2
  6. package/presets/effect/data/variants-are-tagged-unions/RULE.md +17 -4
  7. package/presets/effect/services/dependencies-through-layers/RULE.md +16 -3
  8. package/presets/effect/services/no-mutable-state/RULE.md +7 -1
  9. package/skills/adhere/SKILL.md +180 -31
  10. package/skills/adhere-setup/SKILL.md +142 -16
  11. package/src/cli.ts +56 -23
  12. package/src/config.ts +49 -16
  13. package/src/index.ts +14 -0
  14. package/src/markdown.ts +9 -5
  15. package/src/providers/cloudflare.ts +84 -0
  16. package/src/providers/jev.ts +66 -0
  17. package/src/providers/openai.ts +89 -0
  18. package/src/providers/post.ts +53 -0
  19. package/src/providers/provider.ts +131 -0
  20. package/src/providers/providers.test.ts +276 -0
  21. package/src/providers/systemOne.ts +95 -0
  22. package/src/services/AdhereConfig.ts +2 -4
  23. package/src/services/Credentials.ts +101 -51
  24. package/src/services/Jev.http.ts +165 -170
  25. package/src/services/Jev.provider.test.ts +207 -0
  26. package/src/services/Jev.reads.test.ts +439 -0
  27. package/src/services/Jev.ts +205 -113
  28. package/src/services/NativeLsp.ts +289 -0
  29. package/src/services/SourceReads.test.ts +1040 -0
  30. package/src/services/SourceReads.ts +437 -0
  31. package/src/services/SourceWalker.ts +26 -59
  32. package/src/workflows/audit.ts +440 -265
  33. package/src/workflows/format.ts +74 -17
  34. package/src/workspaces.test.ts +73 -0
  35. package/src/workspaces.ts +91 -0
  36. package/presets/effect/services/operations-have-no-requirements/RULE.md +0 -50
  37. package/presets/effect/services/test-layers-are-in-memory/RULE.md +0 -29
@@ -1,7 +1,14 @@
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
+ } from "#config.ts";
3
10
  import { type Excerpt, type Range } from "#excerpt.ts";
4
- import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
11
+ import { type ScannedFile, WalkUnavailable } from "#models/Audit.ts";
5
12
  import { AdhereConfig } from "#services/AdhereConfig.ts";
6
13
  import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
7
14
  import {
@@ -19,13 +26,15 @@ import {
19
26
  questionRoom,
20
27
  tokensOf,
21
28
  } from "#services/Jev.ts";
22
- import { SourceWalker } from "#services/SourceWalker.ts";
29
+ import { type SourceReadRequest, SourceReads } from "#services/SourceReads.ts";
30
+ import { type SourceFile, SourceWalker } from "#services/SourceWalker.ts";
23
31
  import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
24
32
  import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
25
33
  import { priceOf } from "#pricing.ts";
26
34
  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";
35
+ import { suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
36
+ import { workspacePackagesIn } from "#workspaces.ts";
37
+ import { type Crypto, Duration, Effect, Option, Record, Result, Semaphore, Stream } from "effect";
29
38
 
30
39
  export interface Finding {
31
40
  readonly rule: RuleId;
@@ -65,7 +74,7 @@ export interface AuditResult {
65
74
  readonly judged: number;
66
75
  readonly cached: number;
67
76
  readonly skipped: number;
68
- /** Files whose checks `--limit` left for a later run, none judged in this one. */
77
+ /** Files whose judge or cached-locate requests `--limit` left for a later run. */
69
78
  readonly waiting: number;
70
79
  /** Files the firewall in front of Jev's API refused, with the ID to report each by. */
71
80
  readonly blocked: ReadonlyArray<Blocked>;
@@ -92,6 +101,7 @@ interface PreparedRule {
92
101
  readonly rule: Rule;
93
102
  readonly fingerprint: string;
94
103
  readonly threshold: number;
104
+
95
105
  /** The preset the rule came from, when it is not the project's own. */
96
106
  readonly preset?: string;
97
107
  }
@@ -113,23 +123,14 @@ const LAYOUT = "sections";
113
123
  * matcher re-judges the rule, and a question asked differently re-judges
114
124
  * every rule. So does an edited `appendState`, by its source; what the hook
115
125
  * 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.
126
+ * not. Shared read context and related files do not invalidate judgments.
118
127
  */
119
- const fingerprintOf = (
120
- model: string,
121
- rule: Rule,
122
- workspacePackages: Readonly<Record<string, string>> = {},
123
- ) => {
128
+ const fingerprintOf = (model: string, rule: Rule) => {
124
129
  const matchers = matcherQuestions("", rule).map(([, question]) => question);
125
130
  const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
126
131
  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
132
  return sha256(
132
- `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${packages}`,
133
+ `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
133
134
  );
134
135
  };
135
136
 
@@ -146,7 +147,7 @@ const excerptOf = (lines: ReadonlyArray<string>, { first, last }: Range): Excerp
146
147
  const MATCHER_THRESHOLD = 0.5;
147
148
 
148
149
  /** Whether a judgment's matchers let it stand: a yes to every `appliesTo`, and to no `excludeIf`. */
149
- const inScope = (judgment: Judgment): boolean =>
150
+ export const inScope = (judgment: Pick<Judgment, "appliesTo" | "excludeIf">): boolean =>
150
151
  (judgment.appliesTo ?? []).every((score) => score > MATCHER_THRESHOLD) &&
151
152
  !(judgment.excludeIf ?? []).some((score) => score > MATCHER_THRESHOLD);
152
153
 
@@ -165,19 +166,19 @@ const scopeMisses = (rule: Rule, judgment: Judgment): string =>
165
166
 
166
167
  /** One file before anything is sent: its rules, and which of them the cache answers. */
167
168
  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. */
169
+ /** Only the path and classification; contents are loaded in bounded passes, never kept here. */
170
+ readonly file: SourceFile;
171
+ /** Selected work is known to overflow the cheap budget; deferred files may be unbudgeted. */
173
172
  readonly skipped: boolean;
174
173
  readonly hash: string;
174
+ /** Only this file's imported workspace packages; native reads stay local to send time. */
175
+ readonly readState: ReadState;
175
176
  readonly prepared: Readonly<Record<RuleId, PreparedRule>>;
176
177
  /** Cached judgments still valid for the file's content and the rule's text. */
177
178
  readonly kept: Readonly<Record<RuleId, Judgment>>;
178
179
  readonly pending: Readonly<Record<RuleId, PreparedRule>>;
179
- /** Pending checks `--limit` leaves for a later run. */
180
- readonly deferred: number;
180
+ /** The file's entire request workload, including cached locating, waits for a later run. */
181
+ readonly deferred: boolean;
181
182
  /** Pending rules whose linter check question rides along on this file, each with its tally's key. */
182
183
  readonly sampled: Readonly<Record<RuleId, string>>;
183
184
  /** What the file's `adhere-ignore` comments suppress. */
@@ -187,27 +188,37 @@ export interface FilePlan {
187
188
  /** A run worked out from the files, the rules, and the cache, before any request. */
188
189
  export interface AuditPlan {
189
190
  readonly files: ReadonlyArray<FilePlan>;
191
+ /** The normalization used for content keys, also used when reloading a selected file. */
192
+ readonly includeComments?: boolean;
193
+ /** Explicit --limit 0 intent: no native resolution, judging, or locating, even for cached flags. */
194
+ readonly planOnly?: boolean;
195
+ /** Requested native context, not its results. Only execution discovers and uses SourceReads. */
196
+ readonly nativeReads?: SourceReadRequest;
190
197
  /**
191
198
  * The content hashes of files read that no rule in the run judges, such as
192
199
  * tests when no rule says `tests`: a prune keeps what other runs know of them.
193
200
  */
194
201
  readonly unjudged: ReadonlyArray<string>;
195
- /** Rules that apply to at least one file that is judged. */
202
+ /** Rules applying to files not known to be skipped, including unbudgeted deferred files. */
196
203
  readonly rules: number;
197
- /** Every pair of a judged file and a rule that applies to it. */
204
+ /** File/rule pairs not known to be skipped; deferred work stays counted until budgeted. */
198
205
  readonly checks: number;
199
206
  /** Checks a cached judgment answers. */
200
207
  readonly cached: number;
201
- /** Files too long to judge. */
208
+ /** Files whose selected work is known to exceed the cheap budget. */
202
209
  readonly skipped: number;
203
- /** Checks `--limit` leaves for a later run. */
210
+ /** Files whose judge or cached-locate requests `--limit` leaves for a later run. */
204
211
  readonly deferred: number;
205
- /** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
212
+ /** Judge requests estimated from code and cheap context; native reads may split or skip at send time. */
206
213
  readonly requests: number;
207
- /** About how many input tokens those requests carry, which Jev charges for. */
214
+ /** Cheap-context request estimate for locating known cached flags; omitted means zero. */
215
+ readonly cachedLocateRequests?: number;
216
+ /** Estimated judge input tokens, excluding native reads and locating findings. */
208
217
  readonly tokens: number;
209
218
  /** The model's price per million input tokens, in dollars, when adhere knows it. */
210
219
  readonly price?: number;
220
+ /** The model a config's provider asks in place of Jev, which the plan names instead. */
221
+ readonly provider?: string;
211
222
  /** The run's threshold, which a rule's own, or an override's, replaces for that rule. */
212
223
  readonly threshold: number;
213
224
  /** Below this, a finding's sufficiency adds a warning to it. */
@@ -224,9 +235,62 @@ const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(som
224
235
 
225
236
  const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
226
237
 
238
+ /** Flagged judgments whose section or sufficiency still needs a locate request. */
239
+ const unlocated = (
240
+ prepared: Readonly<Record<RuleId, PreparedRule>>,
241
+ judged: Readonly<Record<RuleId, Judgment>>,
242
+ ) =>
243
+ Record.filter(prepared, (k, id) => {
244
+ const judgment = judged[id];
245
+ return (
246
+ judgment !== undefined &&
247
+ judgment.probability > k.threshold &&
248
+ inScope(judgment) &&
249
+ (judgment.section === undefined || judgment.sufficiency === undefined)
250
+ );
251
+ });
252
+
253
+ const needsRequests = (plan: FilePlan): boolean =>
254
+ !plan.skipped &&
255
+ !plan.deferred &&
256
+ (!isEmpty(plan.pending) || !isEmpty(unlocated(plan.prepared, plan.kept)));
257
+
258
+ /** Located findings survive skipped new work; suppression is applied by the file workflow. */
259
+ const findingsOf = (
260
+ { file, prepared }: FilePlan,
261
+ judged: Readonly<Record<RuleId, Judgment>>,
262
+ original: ReadonlyArray<string>,
263
+ ): ReadonlyArray<Finding> =>
264
+ Object.entries(judged)
265
+ .flatMap(([id, judgment]): ReadonlyArray<Finding> => {
266
+ const k = prepared[id];
267
+ return k !== undefined &&
268
+ judgment.section !== undefined &&
269
+ judgment.probability > k.threshold &&
270
+ inScope(judgment)
271
+ ? [
272
+ {
273
+ rule: id,
274
+ ...(k.preset === undefined ? {} : { preset: k.preset }),
275
+ description: k.rule.description,
276
+ examples: examplesOf(k.rule),
277
+ file: file.path,
278
+ section: judgment.section,
279
+ ...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
280
+ excerpt: excerptOf(original, judgment.section),
281
+ ...(judgment.sufficiency === undefined ? {} : { context: judgment.sufficiency }),
282
+ probability: judgment.probability,
283
+ threshold: k.threshold,
284
+ level: k.rule.level ?? "error",
285
+ },
286
+ ]
287
+ : [];
288
+ })
289
+ .sort((a, b) => (a.lines ?? a.section).first - (b.lines ?? b.section).first);
290
+
227
291
  /** A refusal that names the file it stopped at. Files finished before it are already cached. */
228
292
  const inFile =
229
- (file: ScannedFile) =>
293
+ (file: SourceFile) =>
230
294
  <A, E, R>(self: Effect.Effect<A, E, R>) =>
231
295
  Effect.mapError(self, (problem) =>
232
296
  problem instanceof JevUnavailable
@@ -236,29 +300,68 @@ const inFile =
236
300
  : problem,
237
301
  );
238
302
 
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
- });
303
+ /** Preserve line numbers while blanking directives, and comments other than @adhere notes. */
304
+ const normalized = (original: ReadonlyArray<string>, includeComments: boolean) => {
305
+ const { lines, suppressions } = suppressionsOf(original);
306
+ return {
307
+ lines: includeComments ? lines : withoutComments(lines, isNote),
308
+ suppressions,
309
+ };
310
+ };
311
+
312
+ interface LoadedFile {
313
+ readonly file: ScannedFile;
314
+ readonly original: ReadonlyArray<string>;
315
+ }
316
+
317
+ /** Ignore directives are absent from the code key, but their parsed policy still belongs to the plan. */
318
+ const sameSuppressions = (left: Suppressions, right: Suppressions): boolean => {
319
+ if (left === right) return true;
320
+ const sameRules = (a: ReadonlySet<string>, b: ReadonlySet<string>) =>
321
+ a.size === b.size && [...a].every((rule) => b.has(rule));
322
+ return (
323
+ sameRules(left.file, right.file) &&
324
+ left.statements.length === right.statements.length &&
325
+ left.statements.every((statement, index) => {
326
+ const other = right.statements[index]!;
327
+ return (
328
+ statement.first === other.first &&
329
+ statement.last === other.last &&
330
+ sameRules(statement.rules, other.rules)
331
+ );
332
+ })
333
+ );
258
334
  };
259
335
 
336
+ /** Validate code and ignore policy before sending a request or showing a cached excerpt. */
337
+ const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeComments: boolean) {
338
+ const original = yield* (yield* SourceWalker).read(plan.file);
339
+ const { lines, suppressions } = normalized(original.lines, includeComments);
340
+ if (
341
+ (yield* sha256(lines.join("\n"))) !== plan.hash ||
342
+ !sameSuppressions(plan.suppressions, suppressions)
343
+ ) {
344
+ return yield* WalkUnavailable.make({
345
+ 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.`,
346
+ });
347
+ }
348
+ return { file: { ...plan.file, lines }, original: original.lines } satisfies LoadedFile;
349
+ });
350
+
351
+ /** A cached finding needs the source again even when no request will be sent. */
352
+ const hasLocatedFindings = (plan: FilePlan): boolean =>
353
+ Object.entries(plan.kept).some(([id, judgment]) => {
354
+ const prepared = plan.prepared[id];
355
+ return (
356
+ prepared !== undefined &&
357
+ judgment.section !== undefined &&
358
+ judgment.probability > prepared.threshold &&
359
+ inScope(judgment)
360
+ );
361
+ });
362
+
260
363
  export interface PlanOptions {
261
- /** Judge at most this many checks; the rest wait for a later run. */
364
+ /** Select at most this many files needing judge or cached-locate requests; zero plans only. */
262
365
  readonly limit?: number | undefined;
263
366
  }
264
367
 
@@ -272,72 +375,69 @@ export const planAudit = (
272
375
  Effect.gen(function* () {
273
376
  const config = yield* AdhereConfig;
274
377
  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;
378
+ const workspacePackages = config.reads.includes("workspacePackages")
379
+ ? (config.workspacePackages ?? {})
380
+ : undefined;
381
+ const nativeReads: SourceReadRequest = {
382
+ symbols: config.reads.includes("symbols"),
383
+ references: config.reads.includes("references"),
384
+ includeComments: config.includeComments === true,
385
+ };
386
+ const walker = yield* SourceWalker;
387
+ const files = yield* walker.files;
388
+
389
+ // Each preset's rule, with the preset it came from, for its findings to name.
390
+ const presetOfRule = new Map(
391
+ (config.scopedRules ?? []).flatMap((entry) =>
392
+ entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
393
+ ),
394
+ );
291
395
 
292
396
  // The rules that judge a file: those scoped to it, of which only the rules that say `tests`
293
397
  // judge a test, and none that an adhere-ignore-file comment names.
294
- const configuredRules = (file: ScannedFile) =>
398
+ const configuredRules = (file: SourceFile, suppressions: Suppressions) =>
295
399
  Record.filter(
296
400
  config.scopedRules === undefined
297
401
  ? config.rules
298
402
  : applicableRules(file.path, config.scopedRules),
299
403
  (rule, id) =>
300
404
  judgesFile(rule, file.test === true) &&
301
- !suppressionsIn(file).file.has(shownId({ id, preset: presetOfRule.get(rule) })),
405
+ !suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) })),
302
406
  );
303
407
 
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) => {
408
+ // Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
409
+ const preparedRules = new Map<Rule, PreparedRule>(
410
+ yield* Effect.forEach(
411
+ new Set(config.scopedRules?.map(({ rule }) => rule) ?? Object.values(config.rules)),
412
+ (rule) =>
413
+ Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
328
414
  const preset = presetOfRule.get(rule);
329
415
  return [
330
- id,
331
- {
416
+ rule,
417
+ Object.freeze({
332
418
  rule,
333
419
  fingerprint,
334
420
  threshold: rule.threshold ?? config.threshold,
335
421
  ...(preset === undefined ? {} : { preset }),
336
- },
422
+ }),
337
423
  ] as const;
338
424
  }),
339
- ).pipe(Effect.map(Record.fromEntries));
340
- const answers = yield* cache.get(hash, file.path);
425
+ ),
426
+ );
427
+
428
+ const planFile = Effect.fn("audit.plan")(function* (file: SourceFile) {
429
+ const original = yield* walker.read(file);
430
+ const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
431
+ const rules = configuredRules(file, suppressions);
432
+ const code = lines.join("\n");
433
+ const readState: ReadState =
434
+ workspacePackages === undefined
435
+ ? {}
436
+ : { workspacePackages: workspacePackagesIn(code, workspacePackages) };
437
+ // Hashed even when unjudged or skipped, so a prune keeps what other runs know of its content.
438
+ const hash = yield* sha256(code);
439
+ const prepared = Record.map(rules, (rule) => preparedRules.get(rule)!);
440
+ const answers = isEmpty(prepared) ? {} : yield* cache.get(hash, file.path);
341
441
  const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
342
442
  const answer = answers[fingerprint];
343
443
  return answer === undefined ? Result.failVoid : Result.succeed({ fingerprint, ...answer });
@@ -347,25 +447,22 @@ export const planAudit = (
347
447
  `plan ${file.path}: ${sizeOf(prepared)} rules, ${sizeOf(kept)} cached, ${sizeOf(pending)} to judge`,
348
448
  );
349
449
  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;
450
+ lines,
451
+ plan: {
452
+ file,
453
+ skipped: false,
454
+ hash,
455
+ readState,
456
+ prepared,
457
+ kept,
458
+ pending,
459
+ deferred: false,
460
+ sampled: {},
461
+ suppressions,
462
+ } satisfies FilePlan,
463
+ };
361
464
  });
362
465
 
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
466
  // Preset rules are not the project's to change, so the linter check leaves them out.
370
467
  const presetRules = new Set(
371
468
  (config.scopedRules ?? []).flatMap((entry) =>
@@ -373,33 +470,102 @@ export const planAudit = (
373
470
  ),
374
471
  );
375
472
 
473
+ // Ordered prefetch keeps only a bounded number of normalized files alive. Select whole
474
+ // request-bearing files before budgeting them; cheap overflow leaves the slot for the next path.
475
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
476
+ const limited: Array<FilePlan> = [];
477
+ const unjudged: Array<string> = [];
478
+ const rooms = new Map<FilePlan, number>();
479
+ // Undefined marks a full tally, avoiding repeated lookups without retaining its file map.
480
+ const needs = new Map<
481
+ Rule,
482
+ { readonly key: string; readonly tallied: Tally["files"] } | undefined
483
+ >();
484
+ const baseLoads = new Map<
485
+ SourceFile,
486
+ { readonly requests: number; readonly tokens: number; readonly locating: number }
487
+ >();
488
+ const status = yield* Status;
489
+ let left = options.limit ?? Infinity;
490
+ let done = 0;
491
+ yield* clearingStatus(
492
+ Stream.fromIterable(sorted).pipe(
493
+ Stream.mapEffect(planFile, { concurrency: 8 }),
494
+ Stream.runForEach(({ plan, lines }) =>
495
+ Effect.gen(function* () {
496
+ if (isEmpty(plan.prepared)) {
497
+ unjudged.push(plan.hash);
498
+ } else {
499
+ const pending = plan.pending;
500
+ const flagged = unlocated(plan.prepared, plan.kept);
501
+ const checking = { ...pending, ...flagged };
502
+ if (isEmpty(checking)) {
503
+ limited.push(plan);
504
+ } else if (left <= 0) {
505
+ limited.push({ ...plan, pending: {}, deferred: true });
506
+ } else if (!fits(lines, rulesOf(checking), plan.readState)) {
507
+ limited.push({ ...plan, skipped: true, pending: {} });
508
+ } else {
509
+ left -= 1;
510
+ const selected = plan;
511
+ limited.push(selected);
512
+ if (!isEmpty(checking)) {
513
+ // Exact base estimates while the code is already loaded; retain only scalars.
514
+ const load = isEmpty(pending)
515
+ ? { requests: 0, tokens: 0 }
516
+ : judgeLoad(lines, rulesOf(pending), [], plan.readState);
517
+ baseLoads.set(selected.file, {
518
+ ...load,
519
+ locating: isEmpty(flagged)
520
+ ? 0
521
+ : locateRequests(lines, rulesOf(flagged), plan.readState),
522
+ });
523
+ }
524
+ let candidate = false;
525
+ for (const { rule } of Object.values(pending)) {
526
+ if (presetRules.has(rule)) continue;
527
+ if (!needs.has(rule)) {
528
+ const key = yield* tallyKeyOf(config.model, rule);
529
+ const tallied = (yield* cache.tally(key))?.files ?? {};
530
+ needs.set(
531
+ rule,
532
+ Object.keys(tallied).length >= SAMPLE ? undefined : { key, tallied },
533
+ );
534
+ }
535
+ const need = needs.get(rule);
536
+ if (need !== undefined && need.tallied[selected.file.path] === undefined)
537
+ candidate = true;
538
+ }
539
+ if (candidate) rooms.set(selected, questionRoom(lines, plan.readState));
540
+ }
541
+ }
542
+ done += 1;
543
+ yield* status.show(
544
+ `Planning: ${counted(done)} of ${countOf(sorted.length, "file", "files")}`,
545
+ );
546
+ }),
547
+ ),
548
+ ),
549
+ );
550
+
551
+ // Source text has left the pipeline; sampling needs only candidate metadata and room.
552
+
376
553
  /**
377
554
  * The linter check's samples: for each project rule its tally still needs
378
555
  * answers for, files this run judges it on, spread evenly over them in path
379
556
  * order, skipping files the tally has and files its question would not fit.
380
557
  */
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;
558
+ const withSamples = (plans: ReadonlyArray<FilePlan>) => {
559
+ if (rooms.size === 0) return plans;
394
560
 
395
561
  const candidates = new Map<Rule, Array<{ readonly plan: FilePlan; readonly id: RuleId }>>();
396
562
  const tokens = new Map<Rule, number>();
397
563
  for (const plan of plans) {
398
- let room: number | undefined;
564
+ const room = rooms.get(plan);
565
+ if (room === undefined) continue;
399
566
  for (const [id, { rule }] of Object.entries(plan.pending)) {
400
567
  const need = needs.get(rule);
401
568
  if (need === undefined || need.tallied[plan.file.path] !== undefined) continue;
402
- room ??= questionRoom(plan.file.lines);
403
569
  const size = tokens.get(rule) ?? tokensOf(linterQuestion(rule));
404
570
  tokens.set(rule, size);
405
571
  if (size > room) continue;
@@ -425,58 +591,48 @@ export const planAudit = (
425
591
  const some = sampled.get(plan);
426
592
  return some === undefined ? plan : { ...plan, sampled: some };
427
593
  });
428
- });
594
+ };
429
595
 
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);
596
+ const planned = withSamples(limited);
460
597
  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)),
598
+ // Only added sample questions can change the base judge estimate. Recompute those files
599
+ // after global sampling, with the same snapshot validation as execution. Locating is unchanged.
600
+ yield* Effect.forEach(
601
+ judged.filter((plan) => !isEmpty(plan.sampled)),
602
+ (plan) =>
603
+ Effect.gen(function* () {
604
+ const { file } = yield* reloadFile(plan, config.includeComments === true);
605
+ const load = judgeLoad(
606
+ file.lines,
607
+ rulesOf(plan.pending),
608
+ Object.keys(plan.sampled),
609
+ plan.readState,
610
+ );
611
+ baseLoads.set(plan.file, { ...load, locating: baseLoads.get(plan.file)!.locating });
612
+ }),
613
+ { concurrency: 8, discard: true },
465
614
  );
615
+ const loads = [...baseLoads.values()];
466
616
  const price = priceOf(config.model);
467
617
  const total = (count: (plan: FilePlan) => number) =>
468
618
  judged.reduce((sum, plan) => sum + count(plan), 0);
619
+ const cachedLocateRequests = loads.reduce((sum, load) => sum + load.locating, 0);
469
620
  return {
470
621
  files: planned,
622
+ ...(config.includeComments === true ? { includeComments: true } : {}),
623
+ ...(options.limit === 0 ? { planOnly: true } : {}),
624
+ ...(cachedLocateRequests > 0 ? { cachedLocateRequests } : {}),
625
+ ...(nativeReads.symbols || nativeReads.references ? { nativeReads } : {}),
471
626
  unjudged,
472
627
  rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
473
628
  checks: total((plan) => sizeOf(plan.prepared)),
474
629
  cached: total((plan) => sizeOf(plan.kept)),
475
630
  skipped: planned.length - judged.length,
476
- deferred: total((plan) => plan.deferred),
631
+ deferred: total((plan) => (plan.deferred ? 1 : 0)),
477
632
  requests: loads.reduce((sum, load) => sum + load.requests, 0),
478
633
  tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
479
634
  ...(price === undefined ? {} : { price }),
635
+ ...(config.provider === undefined ? {} : { provider: config.provider.model }),
480
636
  threshold: config.threshold,
481
637
  sufficiencyThreshold: config.sufficiencyThreshold,
482
638
  };
@@ -484,15 +640,37 @@ export const planAudit = (
484
640
 
485
641
  /**
486
642
  * Judges what the plan left pending and locates the findings, a file at a
487
- * time, telling `progress` as each file finishes.
643
+ * time, telling `progress` as each file finishes. A plan-only run reports
644
+ * cached findings without resolving native context or sending requests.
488
645
  */
489
646
  export const executeAudit = (
490
647
  plan: AuditPlan,
491
648
  progress: (done: FileDone) => Effect.Effect<void> = () => Effect.void,
492
- ): Effect.Effect<AuditResult, JevUnavailable, AuditCache | Jev> =>
649
+ ): Effect.Effect<
650
+ AuditResult,
651
+ WalkUnavailable | JevUnavailable,
652
+ AuditCache | Crypto.Crypto | Jev | SourceWalker
653
+ > =>
493
654
  Effect.gen(function* () {
494
655
  const jev = yield* Jev;
495
656
  const cache = yield* AuditCache;
657
+ const planOnly = plan.planOnly === true;
658
+ const nativeReads =
659
+ plan.nativeReads?.symbols || plan.nativeReads?.references ? plan.nativeReads : undefined;
660
+ const eligible = planOnly ? [] : plan.files.filter(needsRequests);
661
+ const sourceReads =
662
+ nativeReads === undefined || eligible.length === 0
663
+ ? Option.none()
664
+ : yield* Effect.serviceOption(SourceReads);
665
+ if (nativeReads !== undefined && eligible.length > 0) {
666
+ if (Option.isNone(sourceReads)) {
667
+ return yield* WalkUnavailable.make({
668
+ message:
669
+ "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.",
670
+ });
671
+ }
672
+ yield* sourceReads.value.prepare(eligible.map(({ file }) => file.path));
673
+ }
496
674
  // Files finish together; each tally is read, extended, and written back by one file at a time.
497
675
  const tallying = yield* Semaphore.make(1);
498
676
 
@@ -517,25 +695,56 @@ export const executeAudit = (
517
695
  ),
518
696
  );
519
697
 
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.
698
+ /** Judges and locates one file, keeping its full read state local and counting send-time splits. */
699
+ const judgeFile = Effect.fn("audit.judgeFile")(function* (
700
+ filePlan: FilePlan,
701
+ { file, original }: LoadedFile,
702
+ sent: (requests: number) => void,
703
+ ) {
704
+ const { hash, prepared, kept, pending, sampled, readState: cheapState } = filePlan;
705
+ // What native reads and a rule's appendState get: the file as written, not as Jev reads it.
532
706
  const asWritten = { path: file.path, contents: original.join("\n") };
707
+ const checking = { ...pending, ...unlocated(prepared, kept) };
708
+ let readState = cheapState;
709
+ if (!isEmpty(checking)) {
710
+ if (nativeReads !== undefined && Option.isSome(sourceReads)) {
711
+ const native = yield* sourceReads.value
712
+ .read(asWritten, file.lines, nativeReads)
713
+ .pipe(
714
+ Effect.mapError((problem) =>
715
+ WalkUnavailable.make({ message: `${file.path}: ${problem.message}` }),
716
+ ),
717
+ );
718
+ readState = {
719
+ ...cheapState,
720
+ ...Record.filter(
721
+ native,
722
+ (_, name) =>
723
+ (name === "symbols" && nativeReads.symbols) ||
724
+ (name === "references" && nativeReads.references),
725
+ ),
726
+ };
727
+ }
728
+ // Budget every request this file can send, never truncating native source to make it fit.
729
+ if (!fits(file.lines, rulesOf(checking), readState)) {
730
+ yield* Effect.logDebug(`${file.path}: full context too long for Jev, skipped`);
731
+ return fileResult("skipped", findingsOf(filePlan, kept, original));
732
+ }
733
+ }
734
+ const room = questionRoom(file.lines, readState);
735
+ const sentSamples = Record.filter(sampled, (_, id) => {
736
+ const k = pending[id];
737
+ return k !== undefined && tokensOf(linterQuestion(k.rule)) <= room;
738
+ });
739
+ const sampleIds = Object.keys(sentSamples);
740
+ if (!isEmpty(pending))
741
+ sent(judgeRequests(file.lines, rulesOf(pending), sampleIds, readState));
533
742
  const { probabilities, linter, matchers }: Judged = isEmpty(pending)
534
743
  ? { probabilities: {}, linter: {} }
535
744
  : yield* jev
536
- .judge(file.lines, rulesOf(pending), Object.keys(sampled), asWritten)
745
+ .judge(file.lines, rulesOf(pending), sampleIds, asWritten, readState)
537
746
  .pipe(inFile(file));
538
- if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
747
+ if (!isEmpty(linter)) yield* record(file.path, sentSamples, linter);
539
748
  const judged: Record<RuleId, Judgment> = { ...kept };
540
749
  for (const [id, probability] of Object.entries(probabilities)) {
541
750
  const k = pending[id];
@@ -553,15 +762,8 @@ export const executeAudit = (
553
762
  }
554
763
  }
555
764
 
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
- });
765
+ const flagged = unlocated(prepared, judged);
766
+ if (!isEmpty(flagged)) sent(locateRequests(file.lines, rulesOf(flagged), readState));
565
767
  // A blocked locate question still leaves the judgments worth caching: a rerun
566
768
  // asks only for the sections again, not for every rule.
567
769
  const {
@@ -572,7 +774,7 @@ export const executeAudit = (
572
774
  readonly blocked: Blocked | undefined;
573
775
  } = isEmpty(flagged)
574
776
  ? { places: {}, blocked: undefined }
575
- : yield* jev.locate(file.lines, rulesOf(flagged), asWritten).pipe(
777
+ : yield* jev.locate(file.lines, rulesOf(flagged), asWritten, readState).pipe(
576
778
  Effect.map((places) => ({ places, blocked: undefined })),
577
779
  Effect.catchTag("JevBlocked", ({ ray }) =>
578
780
  Effect.succeed({ places: {}, blocked: { file: file.path, ray } }),
@@ -587,76 +789,49 @@ export const executeAudit = (
587
789
  const cached = isEmpty(pending) && isEmpty(flagged);
588
790
  if (!cached) yield* cache.put(hash, answersOf(located));
589
791
 
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
- };
792
+ const findings = findingsOf(filePlan, located, original);
793
+ return blocked === undefined
794
+ ? fileResult(cached ? "cached" : "judged", findings)
795
+ : ({ status: "blocked", findings, blocked } satisfies FileResult);
627
796
  });
628
797
 
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
- );
798
+ const auditFile = Effect.fn("audit.file")(function* (filePlan: FilePlan) {
799
+ const requesting = !planOnly && needsRequests(filePlan);
800
+ const loaded =
801
+ requesting || hasLocatedFindings(filePlan)
802
+ ? yield* reloadFile(filePlan, plan.includeComments === true)
803
+ : undefined;
804
+ const keptFindings =
805
+ loaded === undefined ? [] : findingsOf(filePlan, filePlan.kept, loaded.original);
806
+ let sent = 0;
807
+ const judging = (
808
+ !requesting || loaded === undefined
809
+ ? Effect.succeed(
810
+ fileResult(
811
+ filePlan.skipped
812
+ ? "skipped"
813
+ : filePlan.deferred || !isEmpty(filePlan.pending)
814
+ ? "waiting"
815
+ : "cached",
816
+ keptFindings,
817
+ ),
818
+ )
819
+ : judgeFile(filePlan, loaded, (requests) => {
820
+ sent += requests;
821
+ })
822
+ ).pipe(
823
+ // Jev counted more than `fits` estimated. Its count decides: skipped the same way.
824
+ Effect.catchTag("JevOverflow", () => Effect.succeed(fileResult("skipped", keptFindings))),
825
+ // The firewall refuses this file's code every time; the rest of the run goes on.
826
+ Effect.catchTag("JevBlocked", ({ ray }) =>
827
+ Effect.succeed({
828
+ status: "blocked",
829
+ findings: keptFindings,
830
+ blocked: { file: filePlan.file.path, ray },
831
+ } satisfies FileResult),
832
+ ),
833
+ Effect.map((result) => ({ result, requests: sent })),
834
+ );
660
835
  // Everything logged while judging a file, requests included, names the file.
661
836
  // A file that sent nothing, whether cached or too long, is a trace line: a
662
837
  // large repo's cache would otherwise bury the requests.
@@ -666,13 +841,13 @@ export const executeAudit = (
666
841
  `${result.status}: ${requests} ${requests === 1 ? "request" : "requests"}, ${result.findings.length} ${result.findings.length === 1 ? "finding" : "findings"}, ${Math.round(Duration.toMillis(elapsed))} ms`,
667
842
  ),
668
843
  ),
669
- Effect.annotateLogs("file", plan.file.path),
844
+ Effect.annotateLogs("file", filePlan.file.path),
670
845
  );
671
846
  // A finding an adhere-ignore comment covers is left out of the report, and counted.
672
847
  const shown = result.findings.filter(
673
848
  (finding) =>
674
849
  !suppresses(
675
- plan.suppressions,
850
+ filePlan.suppressions,
676
851
  shownId({ id: finding.rule, preset: finding.preset }),
677
852
  finding.lines ?? finding.section,
678
853
  ),
@@ -680,7 +855,7 @@ export const executeAudit = (
680
855
  if (shown.length < result.findings.length) {
681
856
  yield* Effect.logDebug(
682
857
  `${result.findings.length - shown.length} suppressed by adhere-ignore`,
683
- ).pipe(Effect.annotateLogs("file", plan.file.path));
858
+ ).pipe(Effect.annotateLogs("file", filePlan.file.path));
684
859
  }
685
860
  yield* progress({ requests, findings: shown.length });
686
861
  return { ...result, findings: shown, suppressed: result.findings.length - shown.length };