@drkmttr/adhere 0.9.5 → 0.10.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.
Files changed (83) hide show
  1. package/README.md +245 -65
  2. package/package.json +6 -6
  3. package/presets/alchemy/apis/match-remote-errors-by-tag.md +6 -2
  4. package/presets/alchemy/apis/schemas-at-trust-boundaries.md +6 -2
  5. package/presets/alchemy/data/commit-migrations-then-deploy.md +6 -2
  6. package/presets/alchemy/data/hyperdrive-direct-origin.md +6 -2
  7. package/presets/alchemy/data/migrations-use-direct-url.md +6 -2
  8. package/presets/alchemy/data/never-disable-tls-verification.md +6 -2
  9. package/presets/alchemy/data/parameterized-sql.md +6 -2
  10. package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
  11. package/presets/alchemy/data/single-migration-owner.md +6 -2
  12. package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
  13. package/presets/alchemy/durable/io-inside-steps.md +6 -2
  14. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
  15. package/presets/alchemy/durable/stable-step-names.md +6 -2
  16. package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
  17. package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
  18. package/presets/alchemy/providers/idempotent-delete.md +8 -3
  19. package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
  20. package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
  21. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
  22. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
  23. package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
  24. package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
  25. package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
  26. package/presets/alchemy/resources/stable-logical-ids.md +6 -2
  27. package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
  28. package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
  29. package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
  30. package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
  31. package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
  32. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
  33. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
  34. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
  35. package/presets/alchemy/secrets/never-logged-returned-or-output.md +6 -2
  36. package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
  37. package/presets/alchemy/secrets/redacted-until-use.md +6 -2
  38. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
  39. package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
  40. package/presets/alchemy/security/github-secret-not-variable.md +6 -2
  41. package/presets/alchemy/security/identity-from-verified-session.md +6 -2
  42. package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
  43. package/presets/alchemy/security/least-privilege-bindings.md +6 -2
  44. package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
  45. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
  46. package/presets/effect/basics/external-calls-are-resilient.md +6 -2
  47. package/presets/effect/basics/gen-for-sequencing.md +6 -2
  48. package/presets/effect/basics/instrument-with-pipe.md +6 -2
  49. package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
  50. package/presets/effect/config/secrets-are-redacted.md +6 -2
  51. package/presets/effect/config/tests-provide-values-directly.md +7 -2
  52. package/presets/effect/config/validate-with-schema.md +6 -2
  53. package/presets/effect/data/brand-meaningful-primitives.md +6 -2
  54. package/presets/effect/data/variants-are-tagged-unions.md +6 -2
  55. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  56. package/presets/effect/errors/defects-for-bugs.md +6 -2
  57. package/presets/effect/errors/wrap-external-errors.md +6 -2
  58. package/presets/effect/services/dependencies-through-layers.md +6 -2
  59. package/presets/effect/services/fresh-layer-per-test.md +7 -2
  60. package/presets/effect/services/memoize-parameterized-layers.md +6 -2
  61. package/presets/effect/services/no-mutable-state.md +6 -2
  62. package/presets/effect/services/operations-have-no-requirements.md +6 -2
  63. package/presets/effect/services/test-layers-are-in-memory.md +7 -2
  64. package/presets/effect/testing/test-clock-for-time.md +7 -2
  65. package/skills/adhere/SKILL.md +40 -12
  66. package/skills/adhere-fix/SKILL.md +174 -0
  67. package/src/cli.ts +51 -14
  68. package/src/comments.ts +36 -0
  69. package/src/config.ts +98 -14
  70. package/src/contradictions.ts +10 -2
  71. package/src/excerpt.ts +11 -1
  72. package/src/index.ts +1 -1
  73. package/src/init.ts +119 -12
  74. package/src/main.ts +0 -1
  75. package/src/markdown.ts +77 -14
  76. package/src/models/Audit.ts +2 -0
  77. package/src/presets.ts +6 -3
  78. package/src/rules.ts +35 -2
  79. package/src/services/AdhereConfig.ts +36 -13
  80. package/src/services/SourceWalker.ts +32 -10
  81. package/src/suppress.ts +113 -0
  82. package/src/workflows/audit.ts +99 -17
  83. package/src/workflows/format.ts +20 -13
@@ -0,0 +1,113 @@
1
+ import { type Range, statementFrom } from "#excerpt.ts";
2
+ import { scan } from "#highlight.ts";
3
+
4
+ /**
5
+ * What a file's `adhere-ignore` comments suppress. `// adhere-ignore <rules>
6
+ * -- <reason>` on a line of its own suppresses the rules it names in the
7
+ * statement that starts on the next line of code, wherever in it Jev points;
8
+ * at the end of a line of code, in the statement that starts on that line, or
9
+ * the line alone. `// adhere-ignore-file <rules> -- <reason>` suppresses them
10
+ * in the whole file, which they then do not judge. Rules are named as a report
11
+ * names them, separated by commas.
12
+ */
13
+ export interface Suppressions {
14
+ /** Rules suppressed in the whole file. */
15
+ readonly file: ReadonlySet<string>;
16
+ /** Rules suppressed over the lines of one statement. */
17
+ readonly statements: ReadonlyArray<Range & { readonly rules: ReadonlySet<string> }>;
18
+ }
19
+
20
+ export const NO_SUPPRESSIONS: Suppressions = { file: new Set(), statements: [] };
21
+
22
+ /** A directive's comment: whether it covers the file, then what follows, up to a block comment's end. */
23
+ const DIRECTIVE = /^(?:\/\/|\/\*)\s*adhere-ignore(-file)?\s+(.*?)\s*(?:\*\/)?$/s;
24
+
25
+ /** An `adhere-ignore` comment: where it is, and the rules it names. */
26
+ interface Directive {
27
+ readonly line: number;
28
+ /** At the end of a line of code, rather than on a line of its own. */
29
+ readonly trailing: boolean;
30
+ /** `adhere-ignore-file`, for the whole file. */
31
+ readonly file: boolean;
32
+ readonly rules: ReadonlySet<string>;
33
+ }
34
+
35
+ /** The rules a directive names, before any ` -- ` and its reason. None if it is not a directive. */
36
+ const directiveOf = (comment: string): Pick<Directive, "file" | "rules"> | undefined => {
37
+ const match = DIRECTIVE.exec(comment);
38
+ const named = (match?.[2] ?? "").split(/(?:^|\s)--(?:\s|$)/)[0] ?? "";
39
+ const rules = named.split(/[\s,]+/).filter((rule) => rule.length > 0);
40
+ return match === null || rules.length === 0
41
+ ? undefined
42
+ : { file: match[1] !== undefined, rules: new Set(rules) };
43
+ };
44
+
45
+ /** Whether a line holds nothing a statement could start with: blank, or a comment. */
46
+ const isQuiet = (line: string): boolean => {
47
+ const text = line.trim();
48
+ return (
49
+ text.length === 0 || text.startsWith("//") || text.startsWith("/*") || text.startsWith("*")
50
+ );
51
+ };
52
+
53
+ /** The statement that starts on the first line of code after `line`, or that line alone. */
54
+ const statementAfter = (lines: ReadonlyArray<string>, line: number): Range | undefined => {
55
+ for (let next = line + 1; next <= lines.length; next++) {
56
+ if (isQuiet(lines[next - 1] ?? "")) continue;
57
+ return statementFrom(lines, next) ?? { first: next, last: next };
58
+ }
59
+ return undefined;
60
+ };
61
+
62
+ /**
63
+ * A file's lines with its `adhere-ignore` comments blanked, each line keeping
64
+ * its number, so Jev never reads them, and what they suppress.
65
+ */
66
+ export const suppressionsOf = (
67
+ lines: ReadonlyArray<string>,
68
+ ): { readonly lines: ReadonlyArray<string>; readonly suppressions: Suppressions } => {
69
+ const code = lines.join("\n");
70
+ if (!code.includes("adhere-ignore")) return { lines, suppressions: NO_SUPPRESSIONS };
71
+ const tokens = scan(code);
72
+ const found: Array<Directive> = [];
73
+ const kept: Array<string> = [];
74
+ let line = 1;
75
+ // Whether code comes before the current token on its line.
76
+ let afterCode = false;
77
+ for (const token of tokens) {
78
+ const directive = token.kind === "comment" ? directiveOf(token.text) : undefined;
79
+ if (directive !== undefined) found.push({ line, trailing: afterCode, ...directive });
80
+ kept.push(directive === undefined ? token.text : token.text.replace(/[^\n]/g, ""));
81
+ const breaks = token.text.split("\n").length - 1;
82
+ const tail = token.text.slice(token.text.lastIndexOf("\n") + 1);
83
+ if (breaks > 0) afterCode = false;
84
+ if (token.kind !== "comment" && tail.trim().length > 0) afterCode = true;
85
+ line += breaks;
86
+ }
87
+ // What the highlighter could not read, if anything, stays as it was.
88
+ const read = tokens.reduce((length, token) => length + token.text.length, 0);
89
+ const cleaned = (kept.join("") + code.slice(read))
90
+ .split("\n")
91
+ .map((text, index) => (text === lines[index] ? text : text.trimEnd()));
92
+ const statements = found.flatMap(({ line: at, trailing, file, rules }) => {
93
+ if (file) return [];
94
+ const range = trailing
95
+ ? (statementFrom(cleaned, at) ?? { first: at, last: at })
96
+ : statementAfter(cleaned, at);
97
+ return range === undefined ? [] : [{ ...range, rules }];
98
+ });
99
+ return {
100
+ lines: cleaned,
101
+ suppressions: {
102
+ file: new Set(found.filter(({ file }) => file).flatMap(({ rules }) => [...rules])),
103
+ statements,
104
+ },
105
+ };
106
+ };
107
+
108
+ /** Whether a file's comments suppress `rule`, named as a report names it, at `line`. */
109
+ export const suppresses = (suppressions: Suppressions, rule: string, line: number): boolean =>
110
+ suppressions.file.has(rule) ||
111
+ suppressions.statements.some(
112
+ (statement) => statement.rules.has(rule) && statement.first <= line && line <= statement.last,
113
+ );
@@ -1,4 +1,5 @@
1
- import { type Examples, examplesOf, type Rule, type RuleId } from "#config.ts";
1
+ import { isNote, withoutComments } from "#comments.ts";
2
+ import { type Examples, examplesOf, type Level, type Rule, type RuleId } from "#config.ts";
2
3
  import { type Excerpt, excerptOf } from "#excerpt.ts";
3
4
  import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
4
5
  import { AdhereConfig } from "#services/AdhereConfig.ts";
@@ -20,7 +21,8 @@ import { SourceWalker } from "#services/SourceWalker.ts";
20
21
  import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
21
22
  import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
22
23
  import { priceOf } from "#pricing.ts";
23
- import { applicableRules } from "#rules.ts";
24
+ import { applicableRules, judgesFile, shownId } from "#rules.ts";
25
+ import { NO_SUPPRESSIONS, suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
24
26
  import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
25
27
 
26
28
  export interface Finding {
@@ -34,8 +36,17 @@ export interface Finding {
34
36
  /** The code around `line` the report shows. */
35
37
  readonly excerpt: Excerpt;
36
38
  readonly probability: number;
39
+ /** An error fails the run; a warning does not, unless `--deny-warnings`. */
40
+ readonly level: Level;
37
41
  }
38
42
 
43
+ /** The findings that fail a run: its errors, and its warnings too when `denyWarnings`. */
44
+ export const failing = (
45
+ findings: ReadonlyArray<Finding>,
46
+ denyWarnings = false,
47
+ ): ReadonlyArray<Finding> =>
48
+ denyWarnings ? findings : findings.filter((finding) => finding.level === "error");
49
+
39
50
  export interface AuditResult {
40
51
  readonly files: number;
41
52
  readonly judged: number;
@@ -46,6 +57,8 @@ export interface AuditResult {
46
57
  /** Files the firewall in front of Jev's API refused, with the ID to report each by. */
47
58
  readonly blocked: ReadonlyArray<Blocked>;
48
59
  readonly findings: ReadonlyArray<Finding>;
60
+ /** Findings an `adhere-ignore` comment covers, left out of `findings`. */
61
+ readonly suppressed: number;
49
62
  }
50
63
 
51
64
  /** A file whose request the firewall refused, and Cloudflare's Ray ID for the refusal. */
@@ -58,6 +71,8 @@ interface FileResult {
58
71
  readonly status: "judged" | "cached" | "skipped" | "waiting" | "blocked";
59
72
  readonly findings: ReadonlyArray<Finding>;
60
73
  readonly blocked?: Blocked;
74
+ /** Findings an `adhere-ignore` comment covers, left out of `findings`. */
75
+ readonly suppressed?: number;
61
76
  }
62
77
 
63
78
  interface PreparedRule {
@@ -85,7 +100,10 @@ const fingerprintOf = (model: string, rule: Rule) =>
85
100
 
86
101
  /** One file before anything is sent: its rules, and which of them the cache answers. */
87
102
  export interface FilePlan {
103
+ /** The file as Jev reads it: its comments taken out, unless the config keeps them. */
88
104
  readonly file: ScannedFile;
105
+ /** The file's lines as written, which the report's excerpts show. */
106
+ readonly original: ReadonlyArray<string>;
89
107
  /** Too long for Jev: counted, not judged. */
90
108
  readonly skipped: boolean;
91
109
  readonly hash: string;
@@ -97,11 +115,18 @@ export interface FilePlan {
97
115
  readonly deferred: number;
98
116
  /** Pending rules whose linter check question rides along on this file, each with its tally's key. */
99
117
  readonly sampled: Readonly<Record<RuleId, string>>;
118
+ /** What the file's `adhere-ignore` comments suppress. */
119
+ readonly suppressions: Suppressions;
100
120
  }
101
121
 
102
122
  /** A run worked out from the files, the rules, and the cache, before any request. */
103
123
  export interface AuditPlan {
104
124
  readonly files: ReadonlyArray<FilePlan>;
125
+ /**
126
+ * The content hashes of files read that no rule in the run judges, such as
127
+ * tests when no rule says `tests`: a prune keeps what other runs know of them.
128
+ */
129
+ readonly unjudged: ReadonlyArray<string>;
105
130
  /** Rules that apply to at least one file that is judged. */
106
131
  readonly rules: number;
107
132
  /** Every pair of a judged file and a rule that applies to it. */
@@ -165,7 +190,7 @@ const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyA
165
190
 
166
191
  export interface PlanOptions {
167
192
  /** Judge at most this many checks; the rest wait for a later run. */
168
- readonly limit?: number;
193
+ readonly limit?: number | undefined;
169
194
  }
170
195
 
171
196
  export const planAudit = (
@@ -178,13 +203,37 @@ export const planAudit = (
178
203
  Effect.gen(function* () {
179
204
  const config = yield* AdhereConfig;
180
205
  const cache = yield* AuditCache;
181
- const files = yield* (yield* SourceWalker).files;
182
-
183
- const configuredRules = (file: string) =>
184
- config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
206
+ const walked = yield* (yield* SourceWalker).files;
207
+ // Each file as Jev reads it, each line keeping its number: its adhere-ignore comments blanked,
208
+ // and every other comment too but the @adhere notes, unless the config keeps comments. A
209
+ // comment changes nothing a file does, so it should not change the report.
210
+ const suppressed = new Map<string, Suppressions>();
211
+ const originals = new Map<string, ReadonlyArray<string>>();
212
+ const files = walked.map((file) => {
213
+ const { lines: directed, suppressions } = suppressionsOf(file.lines);
214
+ if (suppressions !== NO_SUPPRESSIONS) suppressed.set(file.path, suppressions);
215
+ const lines = config.includeComments === true ? directed : withoutComments(directed, isNote);
216
+ if (lines === file.lines) return file;
217
+ originals.set(file.path, file.lines);
218
+ return { ...file, lines };
219
+ });
220
+ const suppressionsIn = (file: ScannedFile) => suppressed.get(file.path) ?? NO_SUPPRESSIONS;
221
+ const originalOf = (file: ScannedFile) => originals.get(file.path) ?? file.lines;
222
+
223
+ // The rules that judge a file: those scoped to it, of which only the rules that say `tests`
224
+ // judge a test, and none that an adhere-ignore-file comment names.
225
+ const configuredRules = (file: ScannedFile) =>
226
+ Record.filter(
227
+ config.scopedRules === undefined
228
+ ? config.rules
229
+ : applicableRules(file.path, config.scopedRules),
230
+ (rule, id) =>
231
+ judgesFile(rule, file.test === true) &&
232
+ !suppressionsIn(file).file.has(shownId({ id, preset: presetOfRule.get(rule) })),
233
+ );
185
234
 
186
235
  const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
187
- const rules = configuredRules(file.path);
236
+ const rules = configuredRules(file);
188
237
  // Hashed even when skipped, so a prune keeps what other runs know of its content.
189
238
  const hash = yield* sha256(file.lines.join("\n"));
190
239
  // Too long for Jev's context: skipped and counted, rather than refused mid-run.
@@ -199,6 +248,8 @@ export const planAudit = (
199
248
  pending: {},
200
249
  deferred: 0,
201
250
  sampled: {},
251
+ suppressions: suppressionsIn(file),
252
+ original: originalOf(file),
202
253
  } satisfies FilePlan;
203
254
  }
204
255
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
@@ -235,6 +286,8 @@ export const planAudit = (
235
286
  pending,
236
287
  deferred: 0,
237
288
  sampled: {},
289
+ suppressions: suppressionsIn(file),
290
+ original: originalOf(file),
238
291
  } satisfies FilePlan;
239
292
  });
240
293
 
@@ -305,7 +358,16 @@ export const planAudit = (
305
358
  });
306
359
  });
307
360
 
308
- const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
361
+ // A file no rule judges, such as a test when no rule says `tests`, is not part of the run,
362
+ // but its content is: another run's rules may judge it.
363
+ const sorted = files
364
+ .filter((file) => !isEmpty(configuredRules(file)))
365
+ .sort((a, b) => a.path.localeCompare(b.path));
366
+ const unjudged = yield* Effect.forEach(
367
+ files.filter((file) => isEmpty(configuredRules(file))),
368
+ (file) => sha256(file.lines.join("\n")),
369
+ { concurrency: 8 },
370
+ );
309
371
  const status = yield* Status;
310
372
  let done = 0;
311
373
  const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
@@ -337,6 +399,7 @@ export const planAudit = (
337
399
  judged.reduce((sum, plan) => sum + count(plan), 0);
338
400
  return {
339
401
  files: planned,
402
+ unjudged,
340
403
  rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
341
404
  checks: total((plan) => sizeOf(plan.prepared)),
342
405
  cached: total((plan) => sizeOf(plan.kept)),
@@ -386,6 +449,7 @@ export const executeAudit = (
386
449
  /** Judges and locates one file the plan left pending, with the requests that took. */
387
450
  const judgeFile = Effect.fn("audit.judgeFile")(function* ({
388
451
  file,
452
+ original,
389
453
  hash,
390
454
  prepared,
391
455
  kept,
@@ -416,9 +480,10 @@ export const executeAudit = (
416
480
  const {
417
481
  lines,
418
482
  blocked,
419
- }: { readonly lines: Readonly<Record<RuleId, number>>; readonly blocked?: Blocked } = isEmpty(
420
- flagged,
421
- )
483
+ }: {
484
+ readonly lines: Readonly<Record<RuleId, number>>;
485
+ readonly blocked: Blocked | undefined;
486
+ } = isEmpty(flagged)
422
487
  ? { lines: {}, blocked: undefined }
423
488
  : yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
424
489
  Effect.map((lines) => ({ lines, blocked: undefined })),
@@ -449,8 +514,9 @@ export const executeAudit = (
449
514
  examples: examplesOf(k.rule),
450
515
  file: file.path,
451
516
  line: judgment.line,
452
- excerpt: excerptOf(file.lines, judgment.line),
517
+ excerpt: excerptOf(original, judgment.line),
453
518
  probability: judgment.probability,
519
+ level: k.rule.level ?? "error",
454
520
  },
455
521
  ]
456
522
  : [];
@@ -511,8 +577,22 @@ export const executeAudit = (
511
577
  ),
512
578
  Effect.annotateLogs("file", plan.file.path),
513
579
  );
514
- yield* progress({ requests, findings: result.findings.length });
515
- return result;
580
+ // A finding an adhere-ignore comment covers is left out of the report, and counted.
581
+ const shown = result.findings.filter(
582
+ (finding) =>
583
+ !suppresses(
584
+ plan.suppressions,
585
+ shownId({ id: finding.rule, preset: finding.preset }),
586
+ finding.line,
587
+ ),
588
+ );
589
+ if (shown.length < result.findings.length) {
590
+ yield* Effect.logDebug(
591
+ `${result.findings.length - shown.length} suppressed by adhere-ignore`,
592
+ ).pipe(Effect.annotateLogs("file", plan.file.path));
593
+ }
594
+ yield* progress({ requests, findings: shown.length });
595
+ return { ...result, findings: shown, suppressed: result.findings.length - shown.length };
516
596
  });
517
597
 
518
598
  const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
@@ -526,13 +606,15 @@ export const executeAudit = (
526
606
  waiting: count("waiting"),
527
607
  blocked: results.flatMap((result) => (result.blocked === undefined ? [] : [result.blocked])),
528
608
  findings: results.flatMap((result) => result.findings),
609
+ suppressed: results.reduce((sum, result) => sum + (result.suppressed ?? 0), 0),
529
610
  };
530
611
  });
531
612
 
532
613
  /**
533
614
  * Deletes from the cache the answers about content no file has anymore.
534
615
  * Answers to rules the run left out stay, since a run with other presets or
535
- * rules asks them. Only after a run that read every file; a run narrowed by
616
+ * rules asks them, and so do answers about files the run read but judged
617
+ * against no rule. Only after a run that read every file; a run narrowed by
536
618
  * `--filter` cannot tell what content the files it left out have.
537
619
  */
538
620
  export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
@@ -544,7 +626,7 @@ export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
544
626
  );
545
627
  const deleted = yield* cache.prune({
546
628
  // A file too long to judge counts: a run with shorter questions judges it.
547
- hashes: new Set(plan.files.map((file) => file.hash)),
629
+ hashes: new Set([...plan.files.map((file) => file.hash), ...plan.unjudged]),
548
630
  tallies: new Set(tallies),
549
631
  });
550
632
  yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);
@@ -9,6 +9,7 @@ import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit
9
9
  */
10
10
  type Role =
11
11
  | "error"
12
+ | "warning"
12
13
  | "probability"
13
14
  | "description"
14
15
  | "path"
@@ -27,15 +28,16 @@ type Line = ReadonlyArray<Span>;
27
28
  /**
28
29
  * SGR parameters per role. The frame's are set colors, from the 256-color
29
30
  * palette (`38;5;N`) or truecolor (`38;2;R;G;B`), bold with a trailing `;1`,
30
- * and a dim line number. Of the header, only the `×` and the rule are red,
31
- * since a whole line of red is hard to read: the probability is an accent, and
32
- * the description a cool near-white, set here since a theme's own white can
33
- * be gray. The code's are the terminal's own colors (`3N`), so the user's
34
- * theme picks shades that read on its background; none is magenta, which
35
- * would run into the underline.
31
+ * and a dim line number. Of the header, only the `×` and the rule are red, or
32
+ * a warning's `⚠` and rule amber, since a whole line of red is hard to read:
33
+ * the probability is an accent, and the description a cool near-white, set
34
+ * here since a theme's own white can be gray. The code's are the terminal's
35
+ * own colors (`3N`), so the user's theme picks shades that read on its
36
+ * background; none is magenta, which would run into the underline.
36
37
  */
37
38
  const THEME: Readonly<Record<Role, string>> = {
38
39
  error: "38;2;164;20;71;1",
40
+ warning: "38;2;214;154;0;1",
39
41
  probability: "38;5;156",
40
42
  description: "38;2;230;230;255",
41
43
  path: "38;2;5;125;160;1",
@@ -90,9 +92,9 @@ const counted = (count: number, one: string, many: string): string =>
90
92
 
91
93
  const header = (finding: Finding): Line => [
92
94
  span(" "),
93
- span("×", "error"),
95
+ span(finding.level === "warning" ? "⚠" : "×", finding.level),
94
96
  span(" "),
95
- span(shownId({ id: finding.rule, preset: finding.preset }), "error"),
97
+ span(shownId({ id: finding.rule, preset: finding.preset }), finding.level),
96
98
  span(" ("),
97
99
  span(finding.probability.toFixed(2), "probability"),
98
100
  span("): "),
@@ -152,6 +154,13 @@ const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line>
152
154
  ...hint(finding),
153
155
  ];
154
156
 
157
+ /** The findings counted by level: `1 error`, or `2 errors and 1 warning` once there are warnings. */
158
+ const found = (findings: ReadonlyArray<Finding>): string => {
159
+ const warnings = findings.filter((finding) => finding.level === "warning").length;
160
+ const errors = counted(findings.length - warnings, "error", "errors");
161
+ return warnings === 0 ? errors : `${errors} and ${counted(warnings, "warning", "warnings")}`;
162
+ };
163
+
155
164
  const summary = (result: AuditResult): ReadonlyArray<Line> => {
156
165
  const counts = [
157
166
  counted(result.files, "file", "files"),
@@ -160,11 +169,9 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
160
169
  ...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
161
170
  ...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
162
171
  ...(result.blocked.length > 0 ? [`${result.blocked.length} blocked`] : []),
172
+ ...(result.suppressed > 0 ? [`${result.suppressed} suppressed`] : []),
163
173
  ];
164
- return [
165
- [span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
166
- [span(`${counts.join(", ")}.`)],
167
- ];
174
+ return [[span(`Found ${found(result.findings)}.`)], [span(`${counts.join(", ")}.`)]];
168
175
  };
169
176
 
170
177
  /**
@@ -214,7 +221,7 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
214
221
  /** What a run was asked to keep to, for the plan to say. */
215
222
  export interface RunLimits {
216
223
  readonly filter?: ReadonlyArray<string>;
217
- readonly rpm?: number;
224
+ readonly rpm?: number | undefined;
218
225
  }
219
226
 
220
227
  /** About how long `requests` take at `rpm`: seconds under a minute, minutes past it. */