@drkmttr/adhere 0.9.4 → 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 (87) hide show
  1. package/README.md +257 -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 +13 -3
  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/mechanical.ts +2 -2
  77. package/src/models/Audit.ts +2 -0
  78. package/src/presets.ts +9 -3
  79. package/src/pricing.ts +14 -0
  80. package/src/rules.ts +43 -1
  81. package/src/services/AdhereConfig.ts +40 -14
  82. package/src/services/Jev.ts +17 -0
  83. package/src/services/SourceWalker.ts +32 -10
  84. package/src/suppress.ts +113 -0
  85. package/src/wording.ts +2 -2
  86. package/src/workflows/audit.ts +137 -27
  87. package/src/workflows/format.ts +62 -15
@@ -344,6 +344,23 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
344
344
  return requests.map(({ questions }) => ({ ...body, questions }));
345
345
  };
346
346
 
347
+ /**
348
+ * The requests judging `rules` over a file takes, and about how many input
349
+ * tokens they carry, by `tokensOf`: the judge body, split to fit, as `judge`
350
+ * sends it, each request with the file again.
351
+ */
352
+ export const judgeLoad = (
353
+ lines: Lines,
354
+ rules: Rules,
355
+ sampled: ReadonlyArray<RuleId> = [],
356
+ ): { readonly requests: number; readonly tokens: number } => {
357
+ const requests = requestsOf(judgeBody("", lines, rules, sampled));
358
+ return {
359
+ requests: requests.length,
360
+ tokens: requests.reduce((sum, request) => sum + tokensOf(request), 0),
361
+ };
362
+ };
363
+
347
364
  /** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
348
365
  export const judgeRequests = (
349
366
  lines: Lines,
@@ -1,9 +1,10 @@
1
1
  import { matchesGlob } from "node:path";
2
2
  import { Context, Effect, FileSystem, Layer, Path } from "effect";
3
- import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
3
+ import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES, TEST_DIRECTORIES } from "#config.ts";
4
4
  import type { ScannedFile } from "#models/Audit.ts";
5
5
  import { WalkUnavailable } from "#models/Audit.ts";
6
6
  import { walkFiles } from "#walk.ts";
7
+ import { AdhereConfig } from "#services/AdhereConfig.ts";
7
8
  import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
8
9
 
9
10
  /** Walks the repository's TypeScript source, skipping what is not ours to read. */
@@ -11,9 +12,9 @@ export class SourceWalker extends Context.Service<
11
12
  SourceWalker,
12
13
  {
13
14
  /**
14
- * Every TypeScript file under the audit's roots, in directory order. A
15
- * file that cannot be read refuses the walk: a silent gap would report
16
- * a clean audit that was never complete.
15
+ * Every TypeScript file under the audit's roots, in directory order, test
16
+ * files marked. A file that cannot be read refuses the walk: a silent gap
17
+ * would report a clean audit that was never complete.
17
18
  */
18
19
  readonly files: Effect.Effect<ReadonlyArray<ScannedFile>, WalkUnavailable>;
19
20
  }
@@ -50,13 +51,26 @@ export const passesFilter = (relative: string, patterns: ReadonlyArray<string>):
50
51
  return (include.length === 0 || include.some(matches)) && !exclude.some(matches);
51
52
  };
52
53
 
54
+ /**
55
+ * Whether a path, relative to the working directory, is a test: a `.test.ts`
56
+ * or `.spec.ts` file, or any file under a test directory such as `test/`.
57
+ */
58
+ export const isTestFile = (relative: string): boolean => {
59
+ const segments = relative.split(/[\\/]/);
60
+ const name = segments.at(-1) ?? "";
61
+ return (
62
+ name.endsWith(".test.ts") ||
63
+ name.endsWith(".spec.ts") ||
64
+ segments.slice(0, -1).some((segment) => TEST_DIRECTORIES.has(segment))
65
+ );
66
+ };
67
+
53
68
  const isInside = (file: string, root: string): boolean =>
54
69
  file === root || file.startsWith(`${root}/`);
55
70
 
56
71
  const isScannable = (file: string, self: string): boolean =>
57
72
  file.endsWith(".ts") &&
58
73
  !file.endsWith(".d.ts") &&
59
- !file.endsWith(".test.ts") &&
60
74
  !file.endsWith("/adhere.config.ts") &&
61
75
  !file.endsWith("/.adhere.config.ts") &&
62
76
  // The audit's own detector patterns are data, not violations of themselves.
@@ -169,8 +183,10 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
169
183
 
170
184
  /**
171
185
  * The real walker: the roots walked a directory at a time, skipped trees
172
- * left unread, each file read and split into lines. Tests substitute their
173
- * own tree.
186
+ * left unread, each file read and split into lines. The run's filter and the
187
+ * config's `exclude` leave out what they match. Test files are read, and
188
+ * marked, even when no rule judges them, so a prune keeps what other runs
189
+ * know of them. Tests substitute their own tree.
174
190
  * The scan root is the current working directory, so the audit reads the
175
191
  * repository it is run in.
176
192
  */
@@ -179,9 +195,11 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
179
195
  Effect.gen(function* () {
180
196
  const fs = yield* FileSystem.FileSystem;
181
197
  const path = yield* Path.Path;
198
+ const config = yield* AdhereConfig;
182
199
  // No segments: Path resolves against the working directory.
183
200
  const root = path.resolve();
184
201
  const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
202
+ const patterns = [...filter, ...(config.exclude ?? []).map((glob) => `!${glob}`)];
185
203
  const walk = Effect.fn("SourceWalker.files")(function* () {
186
204
  const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
187
205
  const trees = yield* clearingStatus(
@@ -193,12 +211,16 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
193
211
  dir === "." ? "the working directory" : `${dir}/`,
194
212
  self,
195
213
  (relative) =>
196
- // Filter patterns are relative to the working directory, not the tree read.
197
- passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
214
+ // Patterns are relative to the working directory, not the tree read.
215
+ passesFilter(dir === "." ? relative : `${dir}/${relative}`, patterns),
198
216
  ).pipe(Effect.mapError(refused)),
199
217
  ),
200
218
  );
201
- return trees.flat();
219
+ return trees
220
+ .flat()
221
+ .map((file) =>
222
+ isTestFile(path.relative(root, file.path)) ? { ...file, test: true } : file,
223
+ );
202
224
  });
203
225
  return SourceWalker.of({ files: walk() });
204
226
  }),
@@ -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
+ );
package/src/wording.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Example, examplesOf, type Rule } from "#config.ts";
2
- import type { RuleEntry } from "#rules.ts";
2
+ import { type RuleEntry, shownId } from "#rules.ts";
3
3
 
4
4
  type Word = Example["word"];
5
5
 
@@ -83,7 +83,7 @@ export const formatStrays = (strays: ReadonlyArray<Stray>, root: string): Readon
83
83
  : [
84
84
  `${strays.length} ${strays.length === 1 ? "rule is" : "rules are"} not worded as the rule writing tips recommend:`,
85
85
  ...strays.flatMap(({ entry, straying }) => [
86
- ` ${entry.id}${entry.file === undefined ? "" : ` (${displayPath(entry.file, root)})`}`,
86
+ ` ${shownId(entry)}${entry.file === undefined ? "" : ` (${displayPath(entry.file, root)})`}`,
87
87
  ...straying.map((one) => ` ${sentenceOf(one)}`),
88
88
  ]),
89
89
  `Rule writing tips: ${TIPS_URL}`,
@@ -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";
@@ -9,6 +10,7 @@ import {
9
10
  JevUnavailable,
10
11
  type Judged,
11
12
  judgeQuestion,
13
+ judgeLoad,
12
14
  judgeRequests,
13
15
  linterQuestion,
14
16
  locateRequests,
@@ -18,11 +20,15 @@ import {
18
20
  import { SourceWalker } from "#services/SourceWalker.ts";
19
21
  import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
20
22
  import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
21
- import { applicableRules } from "#rules.ts";
23
+ import { priceOf } from "#pricing.ts";
24
+ import { applicableRules, judgesFile, shownId } from "#rules.ts";
25
+ import { NO_SUPPRESSIONS, suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
22
26
  import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
23
27
 
24
28
  export interface Finding {
25
29
  readonly rule: RuleId;
30
+ /** The preset the rule came from, when it is not the project's own. */
31
+ readonly preset?: string;
26
32
  readonly description: string;
27
33
  readonly examples: Examples;
28
34
  readonly file: string;
@@ -30,8 +36,17 @@ export interface Finding {
30
36
  /** The code around `line` the report shows. */
31
37
  readonly excerpt: Excerpt;
32
38
  readonly probability: number;
39
+ /** An error fails the run; a warning does not, unless `--deny-warnings`. */
40
+ readonly level: Level;
33
41
  }
34
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
+
35
50
  export interface AuditResult {
36
51
  readonly files: number;
37
52
  readonly judged: number;
@@ -42,6 +57,8 @@ export interface AuditResult {
42
57
  /** Files the firewall in front of Jev's API refused, with the ID to report each by. */
43
58
  readonly blocked: ReadonlyArray<Blocked>;
44
59
  readonly findings: ReadonlyArray<Finding>;
60
+ /** Findings an `adhere-ignore` comment covers, left out of `findings`. */
61
+ readonly suppressed: number;
45
62
  }
46
63
 
47
64
  /** A file whose request the firewall refused, and Cloudflare's Ray ID for the refusal. */
@@ -54,12 +71,16 @@ interface FileResult {
54
71
  readonly status: "judged" | "cached" | "skipped" | "waiting" | "blocked";
55
72
  readonly findings: ReadonlyArray<Finding>;
56
73
  readonly blocked?: Blocked;
74
+ /** Findings an `adhere-ignore` comment covers, left out of `findings`. */
75
+ readonly suppressed?: number;
57
76
  }
58
77
 
59
78
  interface PreparedRule {
60
79
  readonly rule: Rule;
61
80
  readonly fingerprint: string;
62
81
  readonly threshold: number;
82
+ /** The preset the rule came from, when it is not the project's own. */
83
+ readonly preset?: string;
63
84
  }
64
85
 
65
86
  const fileResult = (
@@ -79,7 +100,10 @@ const fingerprintOf = (model: string, rule: Rule) =>
79
100
 
80
101
  /** One file before anything is sent: its rules, and which of them the cache answers. */
81
102
  export interface FilePlan {
103
+ /** The file as Jev reads it: its comments taken out, unless the config keeps them. */
82
104
  readonly file: ScannedFile;
105
+ /** The file's lines as written, which the report's excerpts show. */
106
+ readonly original: ReadonlyArray<string>;
83
107
  /** Too long for Jev: counted, not judged. */
84
108
  readonly skipped: boolean;
85
109
  readonly hash: string;
@@ -91,11 +115,18 @@ export interface FilePlan {
91
115
  readonly deferred: number;
92
116
  /** Pending rules whose linter check question rides along on this file, each with its tally's key. */
93
117
  readonly sampled: Readonly<Record<RuleId, string>>;
118
+ /** What the file's `adhere-ignore` comments suppress. */
119
+ readonly suppressions: Suppressions;
94
120
  }
95
121
 
96
122
  /** A run worked out from the files, the rules, and the cache, before any request. */
97
123
  export interface AuditPlan {
98
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>;
99
130
  /** Rules that apply to at least one file that is judged. */
100
131
  readonly rules: number;
101
132
  /** Every pair of a judged file and a rule that applies to it. */
@@ -108,6 +139,10 @@ export interface AuditPlan {
108
139
  readonly deferred: number;
109
140
  /** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
110
141
  readonly requests: number;
142
+ /** About how many input tokens those requests carry, which Jev charges for. */
143
+ readonly tokens: number;
144
+ /** The model's price per million input tokens, in dollars, when adhere knows it. */
145
+ readonly price?: number;
111
146
  }
112
147
 
113
148
  /** A file finished, for a progress counter: the requests it took and the findings it has. */
@@ -155,7 +190,7 @@ const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyA
155
190
 
156
191
  export interface PlanOptions {
157
192
  /** Judge at most this many checks; the rest wait for a later run. */
158
- readonly limit?: number;
193
+ readonly limit?: number | undefined;
159
194
  }
160
195
 
161
196
  export const planAudit = (
@@ -168,13 +203,37 @@ export const planAudit = (
168
203
  Effect.gen(function* () {
169
204
  const config = yield* AdhereConfig;
170
205
  const cache = yield* AuditCache;
171
- const files = yield* (yield* SourceWalker).files;
172
-
173
- const configuredRules = (file: string) =>
174
- 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
+ );
175
234
 
176
235
  const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
177
- const rules = configuredRules(file.path);
236
+ const rules = configuredRules(file);
178
237
  // Hashed even when skipped, so a prune keeps what other runs know of its content.
179
238
  const hash = yield* sha256(file.lines.join("\n"));
180
239
  // Too long for Jev's context: skipped and counted, rather than refused mid-run.
@@ -189,16 +248,25 @@ export const planAudit = (
189
248
  pending: {},
190
249
  deferred: 0,
191
250
  sampled: {},
251
+ suppressions: suppressionsIn(file),
252
+ original: originalOf(file),
192
253
  } satisfies FilePlan;
193
254
  }
194
255
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
195
256
  Object.entries(rules),
196
257
  ([id, rule]) =>
197
- Effect.map(
198
- fingerprintOf(config.model, rule),
199
- (fingerprint) =>
200
- [id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
201
- ),
258
+ Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
259
+ const preset = presetOfRule.get(rule);
260
+ return [
261
+ id,
262
+ {
263
+ rule,
264
+ fingerprint,
265
+ threshold: rule.threshold ?? config.threshold,
266
+ ...(preset === undefined ? {} : { preset }),
267
+ },
268
+ ] as const;
269
+ }),
202
270
  ).pipe(Effect.map(Record.fromEntries));
203
271
  const answers = yield* cache.get(hash, file.path);
204
272
  const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
@@ -218,9 +286,17 @@ export const planAudit = (
218
286
  pending,
219
287
  deferred: 0,
220
288
  sampled: {},
289
+ suppressions: suppressionsIn(file),
290
+ original: originalOf(file),
221
291
  } satisfies FilePlan;
222
292
  });
223
293
 
294
+ // Each preset's rule, with the preset it came from, for its findings to name.
295
+ const presetOfRule = new Map(
296
+ (config.scopedRules ?? []).flatMap((entry) =>
297
+ entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
298
+ ),
299
+ );
224
300
  // Preset rules are not the project's to change, so the linter check leaves them out.
225
301
  const presetRules = new Set(
226
302
  (config.scopedRules ?? []).flatMap((entry) =>
@@ -282,7 +358,16 @@ export const planAudit = (
282
358
  });
283
359
  });
284
360
 
285
- 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
+ );
286
371
  const status = yield* Status;
287
372
  let done = 0;
288
373
  const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
@@ -304,20 +389,25 @@ export const planAudit = (
304
389
  options.limit === undefined ? everything : withinLimit(everything, options.limit);
305
390
  const planned = yield* withSamples(limited);
306
391
  const judged = planned.filter((plan) => !plan.skipped);
392
+ const loads = judged.map((plan) =>
393
+ isEmpty(plan.pending)
394
+ ? { requests: 0, tokens: 0 }
395
+ : judgeLoad(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
396
+ );
397
+ const price = priceOf(config.model);
307
398
  const total = (count: (plan: FilePlan) => number) =>
308
399
  judged.reduce((sum, plan) => sum + count(plan), 0);
309
400
  return {
310
401
  files: planned,
402
+ unjudged,
311
403
  rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
312
404
  checks: total((plan) => sizeOf(plan.prepared)),
313
405
  cached: total((plan) => sizeOf(plan.kept)),
314
406
  skipped: planned.length - judged.length,
315
407
  deferred: total((plan) => plan.deferred),
316
- requests: total((plan) =>
317
- isEmpty(plan.pending)
318
- ? 0
319
- : judgeRequests(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
320
- ),
408
+ requests: loads.reduce((sum, load) => sum + load.requests, 0),
409
+ tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
410
+ ...(price === undefined ? {} : { price }),
321
411
  };
322
412
  });
323
413
 
@@ -359,6 +449,7 @@ export const executeAudit = (
359
449
  /** Judges and locates one file the plan left pending, with the requests that took. */
360
450
  const judgeFile = Effect.fn("audit.judgeFile")(function* ({
361
451
  file,
452
+ original,
362
453
  hash,
363
454
  prepared,
364
455
  kept,
@@ -389,9 +480,10 @@ export const executeAudit = (
389
480
  const {
390
481
  lines,
391
482
  blocked,
392
- }: { readonly lines: Readonly<Record<RuleId, number>>; readonly blocked?: Blocked } = isEmpty(
393
- flagged,
394
- )
483
+ }: {
484
+ readonly lines: Readonly<Record<RuleId, number>>;
485
+ readonly blocked: Blocked | undefined;
486
+ } = isEmpty(flagged)
395
487
  ? { lines: {}, blocked: undefined }
396
488
  : yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
397
489
  Effect.map((lines) => ({ lines, blocked: undefined })),
@@ -417,12 +509,14 @@ export const executeAudit = (
417
509
  ? [
418
510
  {
419
511
  rule: id,
512
+ ...(k.preset === undefined ? {} : { preset: k.preset }),
420
513
  description: k.rule.description,
421
514
  examples: examplesOf(k.rule),
422
515
  file: file.path,
423
516
  line: judgment.line,
424
- excerpt: excerptOf(file.lines, judgment.line),
517
+ excerpt: excerptOf(original, judgment.line),
425
518
  probability: judgment.probability,
519
+ level: k.rule.level ?? "error",
426
520
  },
427
521
  ]
428
522
  : [];
@@ -483,8 +577,22 @@ export const executeAudit = (
483
577
  ),
484
578
  Effect.annotateLogs("file", plan.file.path),
485
579
  );
486
- yield* progress({ requests, findings: result.findings.length });
487
- 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 };
488
596
  });
489
597
 
490
598
  const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
@@ -498,13 +606,15 @@ export const executeAudit = (
498
606
  waiting: count("waiting"),
499
607
  blocked: results.flatMap((result) => (result.blocked === undefined ? [] : [result.blocked])),
500
608
  findings: results.flatMap((result) => result.findings),
609
+ suppressed: results.reduce((sum, result) => sum + (result.suppressed ?? 0), 0),
501
610
  };
502
611
  });
503
612
 
504
613
  /**
505
614
  * Deletes from the cache the answers about content no file has anymore.
506
615
  * Answers to rules the run left out stay, since a run with other presets or
507
- * 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
508
618
  * `--filter` cannot tell what content the files it left out have.
509
619
  */
510
620
  export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
@@ -516,7 +626,7 @@ export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
516
626
  );
517
627
  const deleted = yield* cache.prune({
518
628
  // A file too long to judge counts: a run with shorter questions judges it.
519
- hashes: new Set(plan.files.map((file) => file.hash)),
629
+ hashes: new Set([...plan.files.map((file) => file.hash), ...plan.unjudged]),
520
630
  tallies: new Set(tallies),
521
631
  });
522
632
  yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);