@drkmttr/adhere 0.7.1 → 0.8.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.
@@ -79,9 +79,16 @@ const scannable = (root: string, paths: ReadonlyArray<string>, self: string, kee
79
79
  /** One root's paths, filtered to what the audit reads. */
80
80
  const listRoot = Effect.fn("SourceWalker.listRoot")(
81
81
  (fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
82
- Effect.map(fs.readDirectory(root, { recursive: true }), (paths) =>
83
- scannable(root, paths, self, keep),
84
- ),
82
+ Effect.flatMap(fs.readDirectory(root, { recursive: true }), (paths) => {
83
+ const files = scannable(root, paths, self, keep);
84
+ // The paths listed against the files kept: node_modules shows as the difference.
85
+ return Effect.as(
86
+ Effect.logDebug(
87
+ `listed ${root}: ${paths.length} paths, ${files.length} source files to read`,
88
+ ),
89
+ files,
90
+ );
91
+ }),
85
92
  );
86
93
 
87
94
  /** One scannable path, read into lines. */
@@ -89,7 +96,7 @@ const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem,
89
96
  Effect.map(fs.readFileString(path), (content): ScannedFile => ({
90
97
  path,
91
98
  lines: content.split("\n"),
92
- })),
99
+ })).pipe(Effect.tap((file) => Effect.logTrace(`read ${path}: ${file.lines.length} lines`))),
93
100
  );
94
101
 
95
102
  /** Read a batch of paths; the inner loop hoisted out of `forEach`. */
package/src/wording.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { type Example, examplesOf, type Rule } from "#config.ts";
2
+ import type { RuleEntry } from "#rules.ts";
3
+
4
+ type Word = Example["word"];
5
+
6
+ /**
7
+ * A way a rule's wording differs from the README's rule writing tips, which
8
+ * the eval measured: the rule shows code of both kinds, and its description
9
+ * says the words its code is under.
10
+ */
11
+ export type Straying =
12
+ /** The rule has code under `word`, and its description does not say it. */
13
+ | { readonly _tag: "Unsaid"; readonly word: Word }
14
+ /** The rule has code to write, and no example under `word` of code that breaks it. */
15
+ | { readonly _tag: "Unshown"; readonly word: Word }
16
+ /** The description says `word`, which belongs to the other kind of rule. */
17
+ | { readonly _tag: "Mixed"; readonly word: Word };
18
+
19
+ /** A rule whose wording differs from the tips, with each way it does. */
20
+ export interface Stray {
21
+ readonly entry: RuleEntry;
22
+ readonly straying: ReadonlyArray<Straying>;
23
+ }
24
+
25
+ export const TIPS_URL = "https://github.com/darkmatter/adhere#rule-writing-tips";
26
+
27
+ /** Whether a description says a word, whole and in any case: "Must" does, "mustard" does not. */
28
+ const says = (description: string, word: Word): boolean =>
29
+ new RegExp(`\\b${word.replace(" ", "\\s+")}\\b`, "i").test(description);
30
+
31
+ /**
32
+ * How one rule strays from the tips. A rule with only code that breaks it
33
+ * lacks no example: the README keeps that for a rule with no single correct
34
+ * form to show.
35
+ */
36
+ export const strayingOf = (rule: Rule): ReadonlyArray<Straying> => {
37
+ const { good, bad } = examplesOf(rule);
38
+ const guideline = good?.word === "should" || bad?.word === "should not";
39
+ const shown = [good, bad].flatMap((example) => (example === undefined ? [] : [example.word]));
40
+ const unshown: ReadonlyArray<Word> =
41
+ good !== undefined && bad === undefined ? [guideline ? "should not" : "never"] : [];
42
+ // "should" also finds "should not", so a requirement is checked for the one word.
43
+ const others: ReadonlyArray<Word> = guideline ? ["must", "never"] : ["should"];
44
+ return [
45
+ ...shown
46
+ .filter((word) => !says(rule.description, word))
47
+ .map((word): Straying => ({ _tag: "Unsaid", word })),
48
+ ...unshown.map((word): Straying => ({ _tag: "Unshown", word })),
49
+ ...others
50
+ .filter((word) => says(rule.description, word))
51
+ .map((word): Straying => ({ _tag: "Mixed", word })),
52
+ ];
53
+ };
54
+
55
+ /** The rules that stray, in the order given. */
56
+ export const straysAmong = (entries: ReadonlyArray<RuleEntry>): ReadonlyArray<Stray> =>
57
+ entries.flatMap((entry) => {
58
+ const straying = strayingOf(entry.rule);
59
+ return straying.length === 0 ? [] : [{ entry, straying }];
60
+ });
61
+
62
+ /** One way a rule strays, as a sentence. */
63
+ const sentenceOf = (straying: Straying): string => {
64
+ switch (straying._tag) {
65
+ case "Unsaid":
66
+ return `The description does not say "${straying.word}", though the rule has a ${straying.word} example.`;
67
+ case "Unshown":
68
+ return `The rule has no ${straying.word} example. Rules with one example of each kind judge best.`;
69
+ case "Mixed":
70
+ return straying.word === "should"
71
+ ? `The description says "should", a guideline's word, but the rule is a requirement.`
72
+ : `The description says "${straying.word}", a requirement's word, but the rule is a guideline.`;
73
+ }
74
+ };
75
+
76
+ const displayPath = (file: string, root: string): string =>
77
+ file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
78
+
79
+ /** The wording part of `adhere validate`'s report: each rule that strays, then how. */
80
+ export const formatStrays = (strays: ReadonlyArray<Stray>, root: string): ReadonlyArray<string> =>
81
+ strays.length === 0
82
+ ? ["Every rule is worded as the rule writing tips recommend."]
83
+ : [
84
+ `${strays.length} ${strays.length === 1 ? "rule is" : "rules are"} not worded as the rule writing tips recommend:`,
85
+ ...strays.flatMap(({ entry, straying }) => [
86
+ ` ${entry.id}${entry.file === undefined ? "" : ` (${displayPath(entry.file, root)})`}`,
87
+ ...straying.map((one) => ` ${sentenceOf(one)}`),
88
+ ]),
89
+ `Rule writing tips: ${TIPS_URL}`,
90
+ ];
@@ -1,19 +1,24 @@
1
1
  import { type Examples, examplesOf, type Rule, type RuleId } from "#config.ts";
2
+ import { type Excerpt, excerptOf } from "#excerpt.ts";
2
3
  import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
3
4
  import { AdhereConfig } from "#services/AdhereConfig.ts";
4
- import { AuditCache, type Judgment, sha256 } from "#services/AuditCache.ts";
5
+ import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
5
6
  import {
6
7
  fits,
7
8
  Jev,
8
9
  JevUnavailable,
10
+ type Judged,
9
11
  judgeQuestion,
10
12
  judgeRequests,
13
+ linterQuestion,
11
14
  locateRequests,
12
- snippetOf,
15
+ questionRoom,
16
+ tokensOf,
13
17
  } from "#services/Jev.ts";
14
18
  import { SourceWalker } from "#services/SourceWalker.ts";
19
+ import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
15
20
  import { applicableRules } from "#rules.ts";
16
- import { type Crypto, Effect, Record } from "effect";
21
+ import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
17
22
 
18
23
  export interface Finding {
19
24
  readonly rule: RuleId;
@@ -21,7 +26,8 @@ export interface Finding {
21
26
  readonly examples: Examples;
22
27
  readonly file: string;
23
28
  readonly line: number;
24
- readonly snippet: string;
29
+ /** The code around `line` the report shows. */
30
+ readonly excerpt: Excerpt;
25
31
  readonly probability: number;
26
32
  }
27
33
 
@@ -32,12 +38,21 @@ export interface AuditResult {
32
38
  readonly skipped: number;
33
39
  /** Files whose checks `--limit` left for a later run, none judged in this one. */
34
40
  readonly waiting: number;
41
+ /** Files the firewall in front of Jev's API refused, with the ID to report each by. */
42
+ readonly blocked: ReadonlyArray<Blocked>;
35
43
  readonly findings: ReadonlyArray<Finding>;
36
44
  }
37
45
 
46
+ /** A file whose request the firewall refused, and Cloudflare's Ray ID for the refusal. */
47
+ export interface Blocked {
48
+ readonly file: string;
49
+ readonly ray: string;
50
+ }
51
+
38
52
  interface FileResult {
39
- readonly status: "judged" | "cached" | "skipped" | "waiting";
53
+ readonly status: "judged" | "cached" | "skipped" | "waiting" | "blocked";
40
54
  readonly findings: ReadonlyArray<Finding>;
55
+ readonly blocked?: Blocked;
41
56
  }
42
57
 
43
58
  interface PreparedRule {
@@ -73,6 +88,8 @@ export interface FilePlan {
73
88
  readonly pending: Readonly<Record<RuleId, PreparedRule>>;
74
89
  /** Pending checks `--limit` leaves for a later run. */
75
90
  readonly deferred: number;
91
+ /** Pending rules whose linter check question rides along on this file, each with its tally's key. */
92
+ readonly sampled: Readonly<Record<RuleId, string>>;
76
93
  }
77
94
 
78
95
  /** A run worked out from the files, the rules, and the cache, before any request. */
@@ -159,6 +176,7 @@ export const planAudit = (
159
176
  const rules = configuredRules(file.path);
160
177
  // Too long for Jev's context: skipped and counted, rather than refused mid-run.
161
178
  if (!fits(file.lines, rules)) {
179
+ yield* Effect.logTrace(`plan ${file.path}: too long for Jev's context, skipped`);
162
180
  return {
163
181
  file,
164
182
  skipped: true,
@@ -167,6 +185,7 @@ export const planAudit = (
167
185
  kept: {},
168
186
  pending: {},
169
187
  deferred: 0,
188
+ sampled: {},
170
189
  } satisfies FilePlan;
171
190
  }
172
191
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
@@ -179,13 +198,15 @@ export const planAudit = (
179
198
  ),
180
199
  ).pipe(Effect.map(Record.fromEntries));
181
200
  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
- );
201
+ const answers = yield* cache.get(hash, file.path);
202
+ const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
203
+ const answer = answers[fingerprint];
204
+ return answer === undefined ? Result.failVoid : Result.succeed({ fingerprint, ...answer });
205
+ });
188
206
  const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
207
+ yield* Effect.logTrace(
208
+ `plan ${file.path}: ${sizeOf(prepared)} rules, ${sizeOf(kept)} cached, ${sizeOf(pending)} to judge`,
209
+ );
189
210
  return {
190
211
  file,
191
212
  skipped: false,
@@ -194,15 +215,78 @@ export const planAudit = (
194
215
  kept,
195
216
  pending,
196
217
  deferred: 0,
218
+ sampled: {},
197
219
  } satisfies FilePlan;
198
220
  });
199
221
 
222
+ // Preset rules are not the project's to change, so the linter check leaves them out.
223
+ const presetRules = new Set(
224
+ (config.scopedRules ?? []).flatMap((entry) =>
225
+ entry.preset === undefined ? [] : [entry.rule],
226
+ ),
227
+ );
228
+
229
+ /**
230
+ * The linter check's samples: for each project rule its tally still needs
231
+ * answers for, files this run judges it on, spread evenly over them in path
232
+ * order, skipping files the tally has and files its question would not fit.
233
+ */
234
+ const withSamples = Effect.fn("audit.samples")(function* (plans: ReadonlyArray<FilePlan>) {
235
+ const needs = new Map<Rule, { readonly key: string; readonly tallied: Tally["files"] }>();
236
+ for (const plan of plans) {
237
+ for (const { rule } of Object.values(plan.pending)) {
238
+ if (presetRules.has(rule) || needs.has(rule)) continue;
239
+ const key = yield* tallyKeyOf(config.model, rule);
240
+ needs.set(rule, { key, tallied: (yield* cache.tally(key))?.files ?? {} });
241
+ }
242
+ }
243
+ for (const [rule, { tallied }] of needs) {
244
+ if (Object.keys(tallied).length >= SAMPLE) needs.delete(rule);
245
+ }
246
+ if (needs.size === 0) return plans;
247
+
248
+ const candidates = new Map<Rule, Array<{ readonly plan: FilePlan; readonly id: RuleId }>>();
249
+ const tokens = new Map<Rule, number>();
250
+ for (const plan of plans) {
251
+ let room: number | undefined;
252
+ for (const [id, { rule }] of Object.entries(plan.pending)) {
253
+ const need = needs.get(rule);
254
+ if (need === undefined || need.tallied[plan.file.path] !== undefined) continue;
255
+ room ??= questionRoom(plan.file.lines);
256
+ const size = tokens.get(rule) ?? tokensOf(linterQuestion(rule));
257
+ tokens.set(rule, size);
258
+ if (size > room) continue;
259
+ const found = candidates.get(rule);
260
+ if (found === undefined) candidates.set(rule, [{ plan, id }]);
261
+ else found.push({ plan, id });
262
+ }
263
+ }
264
+
265
+ const sampled = new Map<FilePlan, Record<RuleId, string>>();
266
+ for (const [rule, found] of candidates) {
267
+ const need = needs.get(rule);
268
+ if (need === undefined) continue;
269
+ const count = Math.min(found.length, SAMPLE - Object.keys(need.tallied).length);
270
+ for (let index = 0; index < count; index++) {
271
+ const pick = found[Math.floor((index * found.length) / count)];
272
+ if (pick !== undefined) {
273
+ sampled.set(pick.plan, { ...sampled.get(pick.plan), [pick.id]: need.key });
274
+ }
275
+ }
276
+ }
277
+ return plans.map((plan) => {
278
+ const some = sampled.get(plan);
279
+ return some === undefined ? plan : { ...plan, sampled: some };
280
+ });
281
+ });
282
+
200
283
  const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
201
284
  const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
202
285
  concurrency: 8,
203
286
  });
204
- const planned =
287
+ const limited =
205
288
  options.limit === undefined ? everything : withinLimit(everything, options.limit);
289
+ const planned = yield* withSamples(limited);
206
290
  const judged = planned.filter((plan) => !plan.skipped);
207
291
  const total = (count: (plan: FilePlan) => number) =>
208
292
  judged.reduce((sum, plan) => sum + count(plan), 0);
@@ -214,7 +298,9 @@ export const planAudit = (
214
298
  skipped: planned.length - judged.length,
215
299
  deferred: total((plan) => plan.deferred),
216
300
  requests: total((plan) =>
217
- isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
301
+ isEmpty(plan.pending)
302
+ ? 0
303
+ : judgeRequests(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
218
304
  ),
219
305
  };
220
306
  });
@@ -230,6 +316,29 @@ export const executeAudit = (
230
316
  Effect.gen(function* () {
231
317
  const jev = yield* Jev;
232
318
  const cache = yield* AuditCache;
319
+ // Files finish together; each tally is read, extended, and written back by one file at a time.
320
+ const tallying = yield* Semaphore.make(1);
321
+
322
+ /** Adds one file's linter check answers to their rules' tallies. */
323
+ const record = (
324
+ file: string,
325
+ sampled: Readonly<Record<RuleId, string>>,
326
+ linter: Readonly<Record<RuleId, number>>,
327
+ ) =>
328
+ tallying.withPermits(1)(
329
+ Effect.forEach(
330
+ Object.entries(linter),
331
+ ([id, probability]) => {
332
+ const key = sampled[id];
333
+ return key === undefined
334
+ ? Effect.void
335
+ : Effect.flatMap(cache.tally(key), (tally) =>
336
+ cache.putTally(key, { files: { ...tally?.files, [file]: probability } }),
337
+ );
338
+ },
339
+ { discard: true },
340
+ ),
341
+ );
233
342
 
234
343
  /** Judges and locates one file the plan left pending, with the requests that took. */
235
344
  const judgeFile = Effect.fn("audit.judgeFile")(function* ({
@@ -239,10 +348,12 @@ export const executeAudit = (
239
348
  kept,
240
349
  pending,
241
350
  deferred,
351
+ sampled,
242
352
  }: FilePlan) {
243
- const probabilities = isEmpty(pending)
244
- ? {}
245
- : yield* jev.judge(file.lines, rulesOf(pending)).pipe(inFile(file));
353
+ const { probabilities, linter }: Judged = isEmpty(pending)
354
+ ? { probabilities: {}, linter: {} }
355
+ : yield* jev.judge(file.lines, rulesOf(pending), Object.keys(sampled)).pipe(inFile(file));
356
+ if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
246
357
  const judged: Record<RuleId, Judgment> = { ...kept };
247
358
  for (const [id, probability] of Object.entries(probabilities)) {
248
359
  const k = pending[id];
@@ -257,18 +368,29 @@ export const executeAudit = (
257
368
  judgment.line === undefined
258
369
  );
259
370
  });
260
- const lines = isEmpty(flagged)
261
- ? {}
262
- : yield* jev.locate(file.lines, rulesOf(flagged)).pipe(inFile(file));
371
+ // A blocked line question still leaves the judgments worth caching: a rerun
372
+ // asks only for the lines again, not for every rule.
373
+ const {
374
+ lines,
375
+ blocked,
376
+ }: { readonly lines: Readonly<Record<RuleId, number>>; readonly blocked?: Blocked } = isEmpty(
377
+ flagged,
378
+ )
379
+ ? { lines: {}, blocked: undefined }
380
+ : yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
381
+ Effect.map((lines) => ({ lines, blocked: undefined })),
382
+ Effect.catchTag("JevBlocked", ({ ray }) =>
383
+ Effect.succeed({ lines: {}, blocked: { file: file.path, ray } }),
384
+ ),
385
+ inFile(file),
386
+ );
263
387
  const located = Record.map(judged, (judgment, id) => {
264
388
  const line = lines[id];
265
- return line === undefined
266
- ? judgment
267
- : { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
389
+ return line === undefined ? judgment : { ...judgment, line };
268
390
  });
269
391
 
270
392
  const cached = isEmpty(pending) && isEmpty(flagged);
271
- if (!cached) yield* cache.put(file.path, { hash, judgments: located });
393
+ if (!cached) yield* cache.put(hash, answersOf(located));
272
394
 
273
395
  const findings = Object.entries(located)
274
396
  .flatMap(([id, judgment]): ReadonlyArray<Finding> => {
@@ -283,7 +405,7 @@ export const executeAudit = (
283
405
  examples: examplesOf(k.rule),
284
406
  file: file.path,
285
407
  line: judgment.line,
286
- snippet: judgment.snippet ?? "",
408
+ excerpt: excerptOf(file.lines, judgment.line),
287
409
  probability: judgment.probability,
288
410
  },
289
411
  ]
@@ -291,25 +413,60 @@ export const executeAudit = (
291
413
  })
292
414
  .sort((a, b) => a.line - b.line);
293
415
  return {
294
- result: fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings),
416
+ result:
417
+ blocked === undefined
418
+ ? fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings)
419
+ : { status: "blocked", findings, blocked },
295
420
  requests:
296
- (isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
421
+ (isEmpty(pending)
422
+ ? 0
423
+ : judgeRequests(file.lines, rulesOf(pending), Object.keys(sampled))) +
297
424
  (isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
298
425
  };
299
426
  });
300
427
 
301
428
  const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
302
- const { result, requests } = plan.skipped
303
- ? { result: fileResult("skipped", []), requests: 0 }
304
- : yield* judgeFile(plan).pipe(
429
+ const judging = plan.skipped
430
+ ? Effect.succeed({ result: fileResult("skipped", []), requests: 0 })
431
+ : judgeFile(plan).pipe(
305
432
  // Jev counted more than `fits` estimated. Its count decides: skipped the same way.
306
433
  Effect.catchTag("JevOverflow", () =>
307
434
  Effect.succeed({
308
435
  result: fileResult("skipped", []),
309
- requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
436
+ requests: judgeRequests(
437
+ plan.file.lines,
438
+ rulesOf(plan.pending),
439
+ Object.keys(plan.sampled),
440
+ ),
441
+ }),
442
+ ),
443
+ // The firewall refuses this file's code every time; the rest of the run goes on.
444
+ Effect.catchTag("JevBlocked", ({ ray }) =>
445
+ Effect.succeed({
446
+ result: {
447
+ status: "blocked",
448
+ findings: [],
449
+ blocked: { file: plan.file.path, ray },
450
+ } satisfies FileResult,
451
+ requests: judgeRequests(
452
+ plan.file.lines,
453
+ rulesOf(plan.pending),
454
+ Object.keys(plan.sampled),
455
+ ),
310
456
  }),
311
457
  ),
312
458
  );
459
+ // Everything logged while judging a file, requests included, names the file.
460
+ // A file that sent nothing, whether cached or too long, is a trace line: a
461
+ // large repo's cache would otherwise bury the requests.
462
+ const [, { result, requests }] = yield* Effect.timed(judging).pipe(
463
+ Effect.tap(([elapsed, { result, requests }]) =>
464
+ (requests > 0 ? Effect.logDebug : Effect.logTrace)(
465
+ `${result.status}: ${requests} ${requests === 1 ? "request" : "requests"}, ${result.findings.length} ${result.findings.length === 1 ? "finding" : "findings"}, ${Math.round(Duration.toMillis(elapsed))} ms`,
466
+ ),
467
+ ),
468
+ Effect.annotateLogs("file", plan.file.path),
469
+ );
313
470
  yield* progress({ requests, findings: result.findings.length });
314
471
  return result;
315
472
  });
@@ -323,10 +480,33 @@ export const executeAudit = (
323
480
  cached: count("cached"),
324
481
  skipped: count("skipped"),
325
482
  waiting: count("waiting"),
483
+ blocked: results.flatMap((result) => (result.blocked === undefined ? [] : [result.blocked])),
326
484
  findings: results.flatMap((result) => result.findings),
327
485
  };
328
486
  });
329
487
 
488
+ /**
489
+ * Deletes from the cache what the files and rules of a run no longer need:
490
+ * answers about content no file has, answers to rule texts no rule asks, and
491
+ * tallies of rules gone. Only after a run that read every file; a run narrowed
492
+ * by `--filter` cannot tell what the files it left out need.
493
+ */
494
+ export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
495
+ const config = yield* AdhereConfig;
496
+ const cache = yield* AuditCache;
497
+ const read = plan.files.filter((file) => !file.skipped);
498
+ const prepared = read.flatMap((file) => Object.values(file.prepared));
499
+ const tallies = yield* Effect.forEach(new Set(prepared.map(({ rule }) => rule)), (rule) =>
500
+ tallyKeyOf(config.model, rule),
501
+ );
502
+ const deleted = yield* cache.prune({
503
+ hashes: new Set(read.map((file) => file.hash)),
504
+ fingerprints: new Set(prepared.map(({ fingerprint }) => fingerprint)),
505
+ tallies: new Set(tallies),
506
+ });
507
+ yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);
508
+ });
509
+
330
510
  /** Plans the run and executes it, without asking and without a counter. */
331
511
  export const runAudit: Effect.Effect<
332
512
  AuditResult,
@@ -6,7 +6,15 @@ import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit
6
6
  * token of code, named by its role. Layout measures text; only `serialize`
7
7
  * turns a role into color, so no width or blank line ever sees an escape code.
8
8
  */
9
- type Role = "error" | "path" | "lineNumber" | "underline" | "label" | Kind;
9
+ type Role =
10
+ | "error"
11
+ | "probability"
12
+ | "description"
13
+ | "path"
14
+ | "lineNumber"
15
+ | "underline"
16
+ | "label"
17
+ | Kind;
10
18
 
11
19
  interface Span {
12
20
  readonly text: string;
@@ -16,17 +24,23 @@ interface Span {
16
24
  type Line = ReadonlyArray<Span>;
17
25
 
18
26
  /**
19
- * SGR parameters per role. The frame's match `vp lint`: truecolor
20
- * (`38;2;R;G;B`), bold with a trailing `;1`, and a dim line number. The code's
21
- * are the terminal's own colors (`3N`), so the user's theme picks shades that
22
- * read on its background; none is magenta, which would run into the label.
27
+ * SGR parameters per role. The frame's are set colors, from the 256-color
28
+ * palette (`38;5;N`) or truecolor (`38;2;R;G;B`), bold with a trailing `;1`,
29
+ * and a dim line number. Of the header, only the `×` and the rule are red,
30
+ * since a whole line of red is hard to read: the probability is an accent, and
31
+ * the description a cool near-white, set here since a theme's own white can
32
+ * be gray. The code's are the terminal's own colors (`3N`), so the user's
33
+ * theme picks shades that read on its background; none is magenta, which
34
+ * would run into the underline.
23
35
  */
24
36
  const THEME: Readonly<Record<Role, string>> = {
25
- error: "38;2;219;91;81;1",
37
+ error: "38;2;164;20;71;1",
38
+ probability: "38;5;156",
39
+ description: "38;2;230;230;255",
26
40
  path: "38;2;5;125;160;1",
27
41
  lineNumber: "2",
28
42
  underline: "38;2;255;0;175",
29
- label: "38;2;180;105;245",
43
+ label: "38;2;242;205;205",
30
44
  comment: "2",
31
45
  string: "32",
32
46
  constant: "33",
@@ -77,22 +91,40 @@ const header = (finding: Finding): Line => [
77
91
  span(" "),
78
92
  span("×", "error"),
79
93
  span(" "),
80
- span(`${finding.rule} (${finding.probability.toFixed(2)})`, "error"),
81
- span(": "),
82
- span(finding.description, "error"),
94
+ span(finding.rule, "error"),
95
+ span(" ("),
96
+ span(finding.probability.toFixed(2), "probability"),
97
+ span("): "),
98
+ span(finding.description, "description"),
83
99
  ];
84
100
 
85
- /** The path, the offending line numbered in the gutter, and an underline as long as the line. */
101
+ /**
102
+ * The path, then the finding's excerpt with each line numbered in the gutter,
103
+ * and an underline under the offending line's code.
104
+ */
86
105
  const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => {
87
- const number = String(finding.line);
88
- const gutter = span(" ".repeat(number.length + 2));
89
- // A snippet is one line: `snippetOf` trims it out of the file.
90
- const [code = []] = highlighted(finding.snippet);
91
- const underline = "─".repeat(Math.max(1, finding.snippet.trim().length));
106
+ const { start, lines } = finding.excerpt;
107
+ const width = String(start + lines.length - 1).length;
108
+ const gutter = span(" ".repeat(width + 2));
109
+ const offending = lines[finding.line - start] ?? "";
110
+ // The line's own indentation, tabs and all, lines the underline up under its code.
111
+ const indent = offending.slice(0, offending.length - offending.trimStart().length);
112
+ const underline = "─".repeat(Math.max(1, offending.trim().length));
113
+ const location = `:${finding.line}:${indent.length + 1}]`;
92
114
  return [
93
- [gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(`:${number}:1]`)],
94
- [span(" "), span(number, "lineNumber"), span(" │ "), ...code],
95
- [gutter, span("· "), span(underline, "underline")],
115
+ [gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(location)],
116
+ ...highlighted(lines.join("\n")).flatMap((code, index): ReadonlyArray<Line> => {
117
+ const number = String(start + index);
118
+ const numbered = [
119
+ span(" ".repeat(width - number.length + 1)),
120
+ span(number, "lineNumber"),
121
+ span(" │ "),
122
+ ...code,
123
+ ];
124
+ return start + index === finding.line
125
+ ? [numbered, [gutter, span("· "), span(indent), span(underline, "underline")]]
126
+ : [numbered];
127
+ }),
96
128
  [gutter, span("╰────")],
97
129
  ];
98
130
  };
@@ -109,8 +141,9 @@ const hint = (finding: Finding): ReadonlyArray<Line> => {
109
141
  };
110
142
 
111
143
  /**
112
- * One diagnostic, in the frame `vp lint` prints on a terminal: a red header,
113
- * the offending line, a pink underline, and the code to write as the hint.
144
+ * One diagnostic, in the frame `vp lint` prints on a terminal: a header with
145
+ * the rule in red, the code around the offending line, a pink underline under
146
+ * it, and the code to write as the hint.
114
147
  */
115
148
  const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
116
149
  header(finding),
@@ -125,6 +158,7 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
125
158
  `${result.cached} cached`,
126
159
  ...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
127
160
  ...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
161
+ ...(result.blocked.length > 0 ? [`${result.blocked.length} blocked`] : []),
128
162
  ];
129
163
  return [
130
164
  [span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
@@ -132,6 +166,27 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
132
166
  ];
133
167
  };
134
168
 
169
+ /**
170
+ * The files the firewall in front of Jev's API refused, each with the Ray ID
171
+ * TypeSafe AI can look the block up by, so a gap in the audit says where it is.
172
+ */
173
+ const blockedFiles = (result: AuditResult, root: string | undefined): ReadonlyArray<Line> =>
174
+ result.blocked.length === 0
175
+ ? []
176
+ : [
177
+ [],
178
+ [
179
+ span(
180
+ `The firewall in front of Jev's API blocked ${counted(result.blocked.length, "file", "files")}, so ${result.blocked.length === 1 ? "it is" : "they are"} not judged in full. TypeSafe AI can look each block up by its Ray ID:`,
181
+ ),
182
+ ],
183
+ ...result.blocked.map(({ file, ray }) => [
184
+ span(" "),
185
+ span(displayPath(file, root), "path"),
186
+ span(` (Ray ID ${ray})`),
187
+ ]),
188
+ ];
189
+
135
190
  export interface RenderOptions {
136
191
  /** Color the frame the way `vp lint` does on a terminal, and highlight the code. */
137
192
  readonly color?: boolean;
@@ -147,6 +202,7 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
147
202
  [
148
203
  ...result.findings.flatMap((finding) => [...frame(finding, options.root), []]),
149
204
  ...summary(result),
205
+ ...blockedFiles(result, options.root),
150
206
  ].map((line) => serialize(line, options.color === true));
151
207
 
152
208
  /**