@drkmttr/adhere 0.9.5 → 0.11.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 (113) hide show
  1. package/README.md +406 -90
  2. package/package.json +7 -7
  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/private-endpoint-at-runtime.md +6 -2
  10. package/presets/alchemy/data/single-migration-owner.md +6 -2
  11. package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
  12. package/presets/alchemy/durable/io-inside-steps.md +6 -2
  13. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
  14. package/presets/alchemy/durable/stable-step-names.md +6 -2
  15. package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
  16. package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
  17. package/presets/alchemy/providers/idempotent-delete.md +8 -3
  18. package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
  19. package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
  20. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
  21. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
  22. package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
  23. package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
  24. package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
  25. package/presets/alchemy/resources/stable-logical-ids.md +6 -2
  26. package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
  27. package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
  28. package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
  29. package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
  30. package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
  31. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
  32. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
  33. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
  34. package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
  35. package/presets/alchemy/secrets/redacted-until-use.md +6 -2
  36. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
  37. package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
  38. package/presets/alchemy/security/github-secret-not-variable.md +6 -2
  39. package/presets/alchemy/security/identity-from-verified-session.md +6 -2
  40. package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
  41. package/presets/alchemy/security/least-privilege-bindings.md +6 -2
  42. package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
  43. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
  44. package/presets/effect/basics/gen-for-sequencing.md +7 -3
  45. package/presets/effect/basics/instrument-with-pipe.md +6 -2
  46. package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
  47. package/presets/effect/config/secrets-are-redacted.md +6 -2
  48. package/presets/effect/config/tests-provide-values-directly.md +7 -2
  49. package/presets/effect/config/validate-with-schema.md +6 -2
  50. package/presets/effect/data/brand-meaningful-primitives.md +7 -3
  51. package/presets/effect/data/variants-are-tagged-unions.md +7 -3
  52. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  53. package/presets/effect/errors/defects-for-bugs.md +6 -2
  54. package/presets/effect/errors/wrap-external-errors.md +6 -2
  55. package/presets/effect/services/dependencies-through-layers.md +7 -3
  56. package/presets/effect/services/fresh-layer-per-test.md +7 -2
  57. package/presets/effect/services/memoize-parameterized-layers.md +6 -2
  58. package/presets/effect/services/no-mutable-state.md +6 -2
  59. package/presets/effect/services/operations-have-no-requirements.md +6 -2
  60. package/presets/effect/services/test-layers-are-in-memory.md +7 -2
  61. package/presets/effect/testing/test-clock-for-time.md +7 -2
  62. package/presets/react/effects/clean-up-what-they-start.md +23 -0
  63. package/presets/react/effects/event-logic-in-handlers.md +29 -0
  64. package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
  65. package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
  66. package/presets/react/effects/notify-parents-in-handlers.md +29 -0
  67. package/presets/react/server/no-private-data-to-client.md +23 -0
  68. package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
  69. package/presets/react/state/no-contradictory-state.md +17 -0
  70. package/presets/react/state/no-props-copied-into-state.md +23 -0
  71. package/presets/react/state/store-ids-not-copies.md +20 -0
  72. package/presets/security/constant-time-secret-compare.md +22 -0
  73. package/presets/security/no-secrets-in-output.md +19 -0
  74. package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
  75. package/presets/security/parameterized-queries.md +16 -0
  76. package/presets/typescript/async/concurrent-independent-work.md +23 -0
  77. package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
  78. package/presets/typescript/design/no-argument-mutation.md +20 -0
  79. package/presets/typescript/errors/no-swallowed-errors.md +29 -0
  80. package/presets/typescript/errors/release-on-every-path.md +29 -0
  81. package/presets/typescript/testing/assert-outcomes.md +22 -0
  82. package/presets/typescript/testing/independent-tests.md +41 -0
  83. package/presets/typescript/types/assertions-only-when-proven.md +20 -0
  84. package/presets/typescript/types/derive-dont-restate.md +24 -0
  85. package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
  86. package/presets/typescript/types/validate-external-data.md +21 -0
  87. package/skills/adhere/SKILL.md +41 -12
  88. package/skills/adhere-fix/SKILL.md +180 -0
  89. package/src/cli.ts +59 -14
  90. package/src/comments.ts +36 -0
  91. package/src/config.ts +126 -14
  92. package/src/contradictions.ts +10 -2
  93. package/src/excerpt.ts +78 -58
  94. package/src/highlight.ts +2 -0
  95. package/src/index.ts +1 -1
  96. package/src/init.ts +119 -12
  97. package/src/main.ts +0 -1
  98. package/src/markdown.ts +83 -14
  99. package/src/models/Audit.ts +2 -0
  100. package/src/presets.ts +12 -3
  101. package/src/rules.ts +36 -3
  102. package/src/services/AdhereConfig.ts +36 -13
  103. package/src/services/AuditCache.ts +31 -5
  104. package/src/services/Jev.http.ts +76 -11
  105. package/src/services/Jev.ts +191 -73
  106. package/src/services/SourceWalker.ts +51 -13
  107. package/src/suppress.ts +119 -0
  108. package/src/version.ts +25 -1
  109. package/src/workflows/audit.ts +192 -40
  110. package/src/workflows/format.ts +71 -24
  111. package/presets/alchemy/data/parameterized-sql.md +0 -13
  112. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -15
  113. package/presets/effect/basics/external-calls-are-resilient.md +0 -17
@@ -1,5 +1,6 @@
1
- import { type Examples, examplesOf, type Rule, type RuleId } from "#config.ts";
2
- import { type Excerpt, excerptOf } from "#excerpt.ts";
1
+ import { isNote, withoutComments } from "#comments.ts";
2
+ import { type Examples, examplesOf, type Level, type Rule, type RuleId } from "#config.ts";
3
+ import { type Excerpt, type Range } from "#excerpt.ts";
3
4
  import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
4
5
  import { AdhereConfig } from "#services/AdhereConfig.ts";
5
6
  import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
@@ -12,7 +13,9 @@ import {
12
13
  judgeLoad,
13
14
  judgeRequests,
14
15
  linterQuestion,
16
+ type Located,
15
17
  locateRequests,
18
+ matcherQuestions,
16
19
  questionRoom,
17
20
  tokensOf,
18
21
  } from "#services/Jev.ts";
@@ -20,7 +23,8 @@ import { SourceWalker } from "#services/SourceWalker.ts";
20
23
  import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
21
24
  import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
22
25
  import { priceOf } from "#pricing.ts";
23
- import { applicableRules } from "#rules.ts";
26
+ import { applicableRules, judgesFile, shownId } from "#rules.ts";
27
+ import { NO_SUPPRESSIONS, suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
24
28
  import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
25
29
 
26
30
  export interface Finding {
@@ -30,12 +34,30 @@ export interface Finding {
30
34
  readonly description: string;
31
35
  readonly examples: Examples;
32
36
  readonly file: string;
33
- readonly line: number;
34
- /** The code around `line` the report shows. */
37
+ /** The section of the file Jev points at, which a finding is in. */
38
+ readonly section: Range;
39
+ /** The lines in the section the code that breaks the rule starts and ends on, when Jev named them. */
40
+ readonly lines?: Range;
41
+ /** The section's code as written, comments and all, without blank lines at either end. */
35
42
  readonly excerpt: Excerpt;
43
+ /**
44
+ * Jev's probability that the file does not show enough to check the rule,
45
+ * when its probability that it does is below the sufficiency threshold:
46
+ * the report warns, and says how to add the context.
47
+ */
48
+ readonly insufficiency?: number;
36
49
  readonly probability: number;
50
+ /** An error fails the run; a warning does not, unless `--deny-warnings`. */
51
+ readonly level: Level;
37
52
  }
38
53
 
54
+ /** The findings that fail a run: its errors, and its warnings too when `denyWarnings`. */
55
+ export const failing = (
56
+ findings: ReadonlyArray<Finding>,
57
+ denyWarnings = false,
58
+ ): ReadonlyArray<Finding> =>
59
+ denyWarnings ? findings : findings.filter((finding) => finding.level === "error");
60
+
39
61
  export interface AuditResult {
40
62
  readonly files: number;
41
63
  readonly judged: number;
@@ -46,6 +68,8 @@ export interface AuditResult {
46
68
  /** Files the firewall in front of Jev's API refused, with the ID to report each by. */
47
69
  readonly blocked: ReadonlyArray<Blocked>;
48
70
  readonly findings: ReadonlyArray<Finding>;
71
+ /** Findings an `adhere-ignore` comment covers, left out of `findings`. */
72
+ readonly suppressed: number;
49
73
  }
50
74
 
51
75
  /** A file whose request the firewall refused, and Cloudflare's Ray ID for the refusal. */
@@ -58,6 +82,8 @@ interface FileResult {
58
82
  readonly status: "judged" | "cached" | "skipped" | "waiting" | "blocked";
59
83
  readonly findings: ReadonlyArray<Finding>;
60
84
  readonly blocked?: Blocked;
85
+ /** Findings an `adhere-ignore` comment covers, left out of `findings`. */
86
+ readonly suppressed?: number;
61
87
  }
62
88
 
63
89
  interface PreparedRule {
@@ -75,17 +101,58 @@ const fileResult = (
75
101
 
76
102
  const isEmpty = Record.isEmptyReadonlyRecord;
77
103
 
104
+ /** How Jev reads a file, which a judgment depends on: a new layout re-judges every rule. */
105
+ const LAYOUT = "sections";
106
+
78
107
  /**
79
- * What a judgment depends on besides the file: the model, and the question
80
- * asked for the rule, which carries the rule. An edited rule re-judges
81
- * itself, and a question asked differently re-judges every rule.
108
+ * What a judgment depends on besides the file: the model, how the file is
109
+ * laid out, and the questions asked for the rule, which carry the rule: its
110
+ * judge question, and its matchers' when it has any. An edited rule or
111
+ * matcher re-judges the rule, and a question asked differently re-judges
112
+ * every rule.
82
113
  */
83
- const fingerprintOf = (model: string, rule: Rule) =>
84
- sha256(`${model}\u0000${JSON.stringify(judgeQuestion(rule))}`);
114
+ const fingerprintOf = (model: string, rule: Rule) => {
115
+ const matchers = matcherQuestions("", rule).map(([, question]) => question);
116
+ const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
117
+ return sha256(`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}`);
118
+ };
119
+
120
+ /** A section's lines as written, without the blank lines at either end. */
121
+ const excerptOf = (lines: ReadonlyArray<string>, { first, last }: Range): Excerpt => {
122
+ let start = first;
123
+ let end = last;
124
+ while (start < end && (lines[start - 1] ?? "").trim() === "") start += 1;
125
+ while (end > start && (lines[end - 1] ?? "").trim() === "") end -= 1;
126
+ return { start, lines: lines.slice(start - 1, end) };
127
+ };
128
+
129
+ /** A matcher's score counts as a yes above this. */
130
+ const MATCHER_THRESHOLD = 0.5;
131
+
132
+ /** Whether a judgment's matchers let it stand: a yes to every `appliesTo`, and to no `excludeIf`. */
133
+ const inScope = (judgment: Judgment): boolean =>
134
+ (judgment.appliesTo ?? []).every((score) => score > MATCHER_THRESHOLD) &&
135
+ !(judgment.excludeIf ?? []).some((score) => score > MATCHER_THRESHOLD);
136
+
137
+ /** The matchers that dropped a judgment, with their scores, as the debug log names them. */
138
+ const scopeMisses = (rule: Rule, judgment: Judgment): string =>
139
+ [
140
+ ...(rule.appliesTo ?? []).flatMap((matcher, index) => {
141
+ const score = judgment.appliesTo?.[index] ?? 0;
142
+ return score > MATCHER_THRESHOLD ? [] : [`appliesTo "${matcher}" (${score.toFixed(2)})`];
143
+ }),
144
+ ...(rule.excludeIf ?? []).flatMap((matcher, index) => {
145
+ const score = judgment.excludeIf?.[index] ?? 0;
146
+ return score > MATCHER_THRESHOLD ? [`excludeIf "${matcher}" (${score.toFixed(2)})`] : [];
147
+ }),
148
+ ].join(", ");
85
149
 
86
150
  /** One file before anything is sent: its rules, and which of them the cache answers. */
87
151
  export interface FilePlan {
152
+ /** The file as Jev reads it: its comments taken out, unless the config keeps them. */
88
153
  readonly file: ScannedFile;
154
+ /** The file's lines as written, which the report's excerpts show. */
155
+ readonly original: ReadonlyArray<string>;
89
156
  /** Too long for Jev: counted, not judged. */
90
157
  readonly skipped: boolean;
91
158
  readonly hash: string;
@@ -97,11 +164,18 @@ export interface FilePlan {
97
164
  readonly deferred: number;
98
165
  /** Pending rules whose linter check question rides along on this file, each with its tally's key. */
99
166
  readonly sampled: Readonly<Record<RuleId, string>>;
167
+ /** What the file's `adhere-ignore` comments suppress. */
168
+ readonly suppressions: Suppressions;
100
169
  }
101
170
 
102
171
  /** A run worked out from the files, the rules, and the cache, before any request. */
103
172
  export interface AuditPlan {
104
173
  readonly files: ReadonlyArray<FilePlan>;
174
+ /**
175
+ * The content hashes of files read that no rule in the run judges, such as
176
+ * tests when no rule says `tests`: a prune keeps what other runs know of them.
177
+ */
178
+ readonly unjudged: ReadonlyArray<string>;
105
179
  /** Rules that apply to at least one file that is judged. */
106
180
  readonly rules: number;
107
181
  /** Every pair of a judged file and a rule that applies to it. */
@@ -118,6 +192,8 @@ export interface AuditPlan {
118
192
  readonly tokens: number;
119
193
  /** The model's price per million input tokens, in dollars, when adhere knows it. */
120
194
  readonly price?: number;
195
+ /** Below this, a finding's sufficiency adds a warning to it. */
196
+ readonly sufficiencyThreshold: number;
121
197
  }
122
198
 
123
199
  /** A file finished, for a progress counter: the requests it took and the findings it has. */
@@ -165,7 +241,7 @@ const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyA
165
241
 
166
242
  export interface PlanOptions {
167
243
  /** Judge at most this many checks; the rest wait for a later run. */
168
- readonly limit?: number;
244
+ readonly limit?: number | undefined;
169
245
  }
170
246
 
171
247
  export const planAudit = (
@@ -178,13 +254,37 @@ export const planAudit = (
178
254
  Effect.gen(function* () {
179
255
  const config = yield* AdhereConfig;
180
256
  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);
257
+ const walked = yield* (yield* SourceWalker).files;
258
+ // Each file as Jev reads it, each line keeping its number: its adhere-ignore comments blanked,
259
+ // and every other comment too but the @adhere notes, unless the config keeps comments. A
260
+ // comment changes nothing a file does, so it should not change the report.
261
+ const suppressed = new Map<string, Suppressions>();
262
+ const originals = new Map<string, ReadonlyArray<string>>();
263
+ const files = walked.map((file) => {
264
+ const { lines: directed, suppressions } = suppressionsOf(file.lines);
265
+ if (suppressions !== NO_SUPPRESSIONS) suppressed.set(file.path, suppressions);
266
+ const lines = config.includeComments === true ? directed : withoutComments(directed, isNote);
267
+ if (lines === file.lines) return file;
268
+ originals.set(file.path, file.lines);
269
+ return { ...file, lines };
270
+ });
271
+ const suppressionsIn = (file: ScannedFile) => suppressed.get(file.path) ?? NO_SUPPRESSIONS;
272
+ const originalOf = (file: ScannedFile) => originals.get(file.path) ?? file.lines;
273
+
274
+ // The rules that judge a file: those scoped to it, of which only the rules that say `tests`
275
+ // judge a test, and none that an adhere-ignore-file comment names.
276
+ const configuredRules = (file: ScannedFile) =>
277
+ Record.filter(
278
+ config.scopedRules === undefined
279
+ ? config.rules
280
+ : applicableRules(file.path, config.scopedRules),
281
+ (rule, id) =>
282
+ judgesFile(rule, file.test === true) &&
283
+ !suppressionsIn(file).file.has(shownId({ id, preset: presetOfRule.get(rule) })),
284
+ );
185
285
 
186
286
  const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
187
- const rules = configuredRules(file.path);
287
+ const rules = configuredRules(file);
188
288
  // Hashed even when skipped, so a prune keeps what other runs know of its content.
189
289
  const hash = yield* sha256(file.lines.join("\n"));
190
290
  // Too long for Jev's context: skipped and counted, rather than refused mid-run.
@@ -199,6 +299,8 @@ export const planAudit = (
199
299
  pending: {},
200
300
  deferred: 0,
201
301
  sampled: {},
302
+ suppressions: suppressionsIn(file),
303
+ original: originalOf(file),
202
304
  } satisfies FilePlan;
203
305
  }
204
306
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
@@ -235,6 +337,8 @@ export const planAudit = (
235
337
  pending,
236
338
  deferred: 0,
237
339
  sampled: {},
340
+ suppressions: suppressionsIn(file),
341
+ original: originalOf(file),
238
342
  } satisfies FilePlan;
239
343
  });
240
344
 
@@ -305,7 +409,16 @@ export const planAudit = (
305
409
  });
306
410
  });
307
411
 
308
- const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
412
+ // A file no rule judges, such as a test when no rule says `tests`, is not part of the run,
413
+ // but its content is: another run's rules may judge it.
414
+ const sorted = files
415
+ .filter((file) => !isEmpty(configuredRules(file)))
416
+ .sort((a, b) => a.path.localeCompare(b.path));
417
+ const unjudged = yield* Effect.forEach(
418
+ files.filter((file) => isEmpty(configuredRules(file))),
419
+ (file) => sha256(file.lines.join("\n")),
420
+ { concurrency: 8 },
421
+ );
309
422
  const status = yield* Status;
310
423
  let done = 0;
311
424
  const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
@@ -337,6 +450,7 @@ export const planAudit = (
337
450
  judged.reduce((sum, plan) => sum + count(plan), 0);
338
451
  return {
339
452
  files: planned,
453
+ unjudged,
340
454
  rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
341
455
  checks: total((plan) => sizeOf(plan.prepared)),
342
456
  cached: total((plan) => sizeOf(plan.kept)),
@@ -345,6 +459,7 @@ export const planAudit = (
345
459
  requests: loads.reduce((sum, load) => sum + load.requests, 0),
346
460
  tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
347
461
  ...(price === undefined ? {} : { price }),
462
+ sufficiencyThreshold: config.sufficiencyThreshold,
348
463
  };
349
464
  });
350
465
 
@@ -386,6 +501,7 @@ export const executeAudit = (
386
501
  /** Judges and locates one file the plan left pending, with the requests that took. */
387
502
  const judgeFile = Effect.fn("audit.judgeFile")(function* ({
388
503
  file,
504
+ original,
389
505
  hash,
390
506
  prepared,
391
507
  kept,
@@ -393,14 +509,25 @@ export const executeAudit = (
393
509
  deferred,
394
510
  sampled,
395
511
  }: FilePlan) {
396
- const { probabilities, linter }: Judged = isEmpty(pending)
512
+ const { probabilities, linter, matchers }: Judged = isEmpty(pending)
397
513
  ? { probabilities: {}, linter: {} }
398
514
  : yield* jev.judge(file.lines, rulesOf(pending), Object.keys(sampled)).pipe(inFile(file));
399
515
  if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
400
516
  const judged: Record<RuleId, Judgment> = { ...kept };
401
517
  for (const [id, probability] of Object.entries(probabilities)) {
402
518
  const k = pending[id];
403
- if (k !== undefined) judged[id] = { fingerprint: k.fingerprint, probability };
519
+ if (k !== undefined) {
520
+ judged[id] = { fingerprint: k.fingerprint, probability, ...matchers?.[id] };
521
+ }
522
+ }
523
+ // Above its threshold, but its matchers put the code out of the rule's scope.
524
+ for (const [id, judgment] of Object.entries(judged)) {
525
+ const k = prepared[id];
526
+ if (k !== undefined && judgment.probability > k.threshold && !inScope(judgment)) {
527
+ yield* Effect.logDebug(
528
+ `${file.path}: ${id} (${judgment.probability.toFixed(2)}) dropped by ${scopeMisses(k.rule, judgment)}`,
529
+ );
530
+ }
404
531
  }
405
532
 
406
533
  const flagged = Record.filter(prepared, (k, id) => {
@@ -408,28 +535,30 @@ export const executeAudit = (
408
535
  return (
409
536
  judgment !== undefined &&
410
537
  judgment.probability > k.threshold &&
411
- judgment.line === undefined
538
+ inScope(judgment) &&
539
+ (judgment.section === undefined || judgment.sufficiency === undefined)
412
540
  );
413
541
  });
414
- // A blocked line question still leaves the judgments worth caching: a rerun
415
- // asks only for the lines again, not for every rule.
542
+ // A blocked locate question still leaves the judgments worth caching: a rerun
543
+ // asks only for the sections again, not for every rule.
416
544
  const {
417
- lines,
545
+ places,
418
546
  blocked,
419
- }: { readonly lines: Readonly<Record<RuleId, number>>; readonly blocked?: Blocked } = isEmpty(
420
- flagged,
421
- )
422
- ? { lines: {}, blocked: undefined }
547
+ }: {
548
+ readonly places: Readonly<Record<RuleId, Located>>;
549
+ readonly blocked: Blocked | undefined;
550
+ } = isEmpty(flagged)
551
+ ? { places: {}, blocked: undefined }
423
552
  : yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
424
- Effect.map((lines) => ({ lines, blocked: undefined })),
553
+ Effect.map((places) => ({ places, blocked: undefined })),
425
554
  Effect.catchTag("JevBlocked", ({ ray }) =>
426
- Effect.succeed({ lines: {}, blocked: { file: file.path, ray } }),
555
+ Effect.succeed({ places: {}, blocked: { file: file.path, ray } }),
427
556
  ),
428
557
  inFile(file),
429
558
  );
430
559
  const located = Record.map(judged, (judgment, id) => {
431
- const line = lines[id];
432
- return line === undefined ? judgment : { ...judgment, line };
560
+ const place = places[id];
561
+ return place === undefined ? judgment : { ...judgment, ...place };
433
562
  });
434
563
 
435
564
  const cached = isEmpty(pending) && isEmpty(flagged);
@@ -439,8 +568,9 @@ export const executeAudit = (
439
568
  .flatMap(([id, judgment]): ReadonlyArray<Finding> => {
440
569
  const k = prepared[id];
441
570
  return k !== undefined &&
442
- judgment.line !== undefined &&
443
- judgment.probability > k.threshold
571
+ judgment.section !== undefined &&
572
+ judgment.probability > k.threshold &&
573
+ inScope(judgment)
444
574
  ? [
445
575
  {
446
576
  rule: id,
@@ -448,14 +578,20 @@ export const executeAudit = (
448
578
  description: k.rule.description,
449
579
  examples: examplesOf(k.rule),
450
580
  file: file.path,
451
- line: judgment.line,
452
- excerpt: excerptOf(file.lines, judgment.line),
581
+ section: judgment.section,
582
+ ...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
583
+ excerpt: excerptOf(original, judgment.section),
584
+ ...(judgment.sufficiency !== undefined &&
585
+ judgment.sufficiency < plan.sufficiencyThreshold
586
+ ? { insufficiency: 1 - judgment.sufficiency }
587
+ : {}),
453
588
  probability: judgment.probability,
589
+ level: k.rule.level ?? "error",
454
590
  },
455
591
  ]
456
592
  : [];
457
593
  })
458
- .sort((a, b) => a.line - b.line);
594
+ .sort((a, b) => (a.lines ?? a.section).first - (b.lines ?? b.section).first);
459
595
  return {
460
596
  result:
461
597
  blocked === undefined
@@ -511,8 +647,22 @@ export const executeAudit = (
511
647
  ),
512
648
  Effect.annotateLogs("file", plan.file.path),
513
649
  );
514
- yield* progress({ requests, findings: result.findings.length });
515
- return result;
650
+ // A finding an adhere-ignore comment covers is left out of the report, and counted.
651
+ const shown = result.findings.filter(
652
+ (finding) =>
653
+ !suppresses(
654
+ plan.suppressions,
655
+ shownId({ id: finding.rule, preset: finding.preset }),
656
+ finding.lines ?? finding.section,
657
+ ),
658
+ );
659
+ if (shown.length < result.findings.length) {
660
+ yield* Effect.logDebug(
661
+ `${result.findings.length - shown.length} suppressed by adhere-ignore`,
662
+ ).pipe(Effect.annotateLogs("file", plan.file.path));
663
+ }
664
+ yield* progress({ requests, findings: shown.length });
665
+ return { ...result, findings: shown, suppressed: result.findings.length - shown.length };
516
666
  });
517
667
 
518
668
  const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
@@ -526,13 +676,15 @@ export const executeAudit = (
526
676
  waiting: count("waiting"),
527
677
  blocked: results.flatMap((result) => (result.blocked === undefined ? [] : [result.blocked])),
528
678
  findings: results.flatMap((result) => result.findings),
679
+ suppressed: results.reduce((sum, result) => sum + (result.suppressed ?? 0), 0),
529
680
  };
530
681
  });
531
682
 
532
683
  /**
533
684
  * Deletes from the cache the answers about content no file has anymore.
534
685
  * 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
686
+ * rules asks them, and so do answers about files the run read but judged
687
+ * against no rule. Only after a run that read every file; a run narrowed by
536
688
  * `--filter` cannot tell what content the files it left out have.
537
689
  */
538
690
  export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
@@ -544,7 +696,7 @@ export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
544
696
  );
545
697
  const deleted = yield* cache.prune({
546
698
  // A file too long to judge counts: a run with shorter questions judges it.
547
- hashes: new Set(plan.files.map((file) => file.hash)),
699
+ hashes: new Set([...plan.files.map((file) => file.hash), ...plan.unjudged]),
548
700
  tallies: new Set(tallies),
549
701
  });
550
702
  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("): "),
@@ -100,36 +102,74 @@ const header = (finding: Finding): Line => [
100
102
  ];
101
103
 
102
104
  /**
103
- * The path, then the finding's excerpt with each line numbered in the gutter,
104
- * and an underline under the offending line's code.
105
+ * The path at the lines Jev points at, or the section's first line when it
106
+ * named none, then the section with each line numbered in the gutter. One
107
+ * line Jev points at is underlined under its code; several are marked by a
108
+ * bar in a column of their own beside the code, as `vp lint` marks a span,
109
+ * so each line of code keeps one line of the report.
105
110
  */
106
111
  const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => {
107
112
  const { start, lines } = finding.excerpt;
108
113
  const width = String(start + lines.length - 1).length;
109
114
  const gutter = span(" ".repeat(width + 2));
110
- const offending = lines[finding.line - start] ?? "";
111
- // The line's own indentation, tabs and all, lines the underline up under its code.
112
- const indent = offending.slice(0, offending.length - offending.trimStart().length);
113
- const underline = "─".repeat(Math.max(1, offending.trim().length));
114
- const location = `:${finding.line}:${indent.length + 1}]`;
115
+ const indentOf = (line: string) => line.slice(0, line.length - line.trimStart().length);
116
+ const pointed = finding.lines;
117
+ const at = pointed?.first ?? start;
118
+ const location = `:${at}:${indentOf(lines[at - start] ?? "").length + 1}]`;
119
+ const spanning = pointed !== undefined && pointed.last > pointed.first;
120
+ const inside = (line: number) =>
121
+ pointed !== undefined && pointed.first <= line && line <= pointed.last;
115
122
  return [
116
123
  [gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(location)],
117
124
  ...highlighted(lines.join("\n")).flatMap((code, index): ReadonlyArray<Line> => {
118
125
  const number = String(start + index);
126
+ const marker = spanning ? [inside(start + index) ? span("┃ ", "underline") : span(" ")] : [];
119
127
  const numbered = [
120
128
  span(" ".repeat(width - number.length + 1)),
121
129
  span(number, "lineNumber"),
122
130
  span(" │ "),
131
+ ...marker,
123
132
  ...code,
124
133
  ];
125
- return start + index === finding.line
126
- ? [numbered, [gutter, span("· "), span(indent), span(underline, "underline")]]
134
+ const text = lines[index] ?? "";
135
+ // The line's own indentation, tabs and all, lines the underline up under its code.
136
+ return !spanning && inside(start + index) && text.trim() !== ""
137
+ ? [
138
+ numbered,
139
+ [
140
+ gutter,
141
+ span("· "),
142
+ span(indentOf(text)),
143
+ span("─".repeat(text.trim().length), "underline"),
144
+ ],
145
+ ]
127
146
  : [numbered];
128
147
  }),
129
148
  [gutter, span("╰────")],
130
149
  ];
131
150
  };
132
151
 
152
+ /**
153
+ * The warning on a finding whose file may not show enough to check its rule,
154
+ * with Jev's probability that it does not, and help: how to add what the
155
+ * code relies on as a note Jev reads, in the form the README asks for.
156
+ */
157
+ const insufficient = (finding: Finding): ReadonlyArray<Line> =>
158
+ finding.insufficiency === undefined
159
+ ? []
160
+ : [
161
+ [
162
+ span(" warning: ", "warning"),
163
+ span(
164
+ `this file may not show enough to check this rule (Jev: ${finding.insufficiency.toFixed(2)} that it does not)`,
165
+ ),
166
+ ],
167
+ ...hanging(span(" help: ", "label"), [
168
+ [span("add what the code relies on outside this file as a note Jev reads:")],
169
+ highlighted("// @adhere <the fact>, and how you know it")[0] ?? [],
170
+ ]),
171
+ ];
172
+
133
173
  /**
134
174
  * The code to write as the hint. A rule with only code not to write shows
135
175
  * that instead, labeled with its word: `never`, or `should not`.
@@ -143,15 +183,24 @@ const hint = (finding: Finding): ReadonlyArray<Line> => {
143
183
 
144
184
  /**
145
185
  * One diagnostic, in the frame `vp lint` prints on a terminal: a header with
146
- * the rule in red, the code around the offending line, a pink underline under
147
- * it, and the code to write as the hint.
186
+ * the rule in red, the section of the file Jev points at with the lines it
187
+ * names in pink, a warning when the file may not show enough to decide, and
188
+ * the code to write as the hint.
148
189
  */
149
190
  const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
150
191
  header(finding),
151
192
  ...excerpt(finding, root),
193
+ ...insufficient(finding),
152
194
  ...hint(finding),
153
195
  ];
154
196
 
197
+ /** The findings counted by level: `1 error`, or `2 errors and 1 warning` once there are warnings. */
198
+ const found = (findings: ReadonlyArray<Finding>): string => {
199
+ const warnings = findings.filter((finding) => finding.level === "warning").length;
200
+ const errors = counted(findings.length - warnings, "error", "errors");
201
+ return warnings === 0 ? errors : `${errors} and ${counted(warnings, "warning", "warnings")}`;
202
+ };
203
+
155
204
  const summary = (result: AuditResult): ReadonlyArray<Line> => {
156
205
  const counts = [
157
206
  counted(result.files, "file", "files"),
@@ -160,11 +209,9 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
160
209
  ...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
161
210
  ...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
162
211
  ...(result.blocked.length > 0 ? [`${result.blocked.length} blocked`] : []),
212
+ ...(result.suppressed > 0 ? [`${result.suppressed} suppressed`] : []),
163
213
  ];
164
- return [
165
- [span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
166
- [span(`${counts.join(", ")}.`)],
167
- ];
214
+ return [[span(`Found ${found(result.findings)}.`)], [span(`${counts.join(", ")}.`)]];
168
215
  };
169
216
 
170
217
  /**
@@ -214,7 +261,7 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
214
261
  /** What a run was asked to keep to, for the plan to say. */
215
262
  export interface RunLimits {
216
263
  readonly filter?: ReadonlyArray<string>;
217
- readonly rpm?: number;
264
+ readonly rpm?: number | undefined;
218
265
  }
219
266
 
220
267
  /** About how long `requests` take at `rpm`: seconds under a minute, minutes past it. */
@@ -1,13 +0,0 @@
1
- ---
2
- description: SQL must pass values as parameters, through tagged-template interpolation, sql.in, sql.insert, or prepare().bind(), never by string concatenation or a template string handed to an unsafe or raw API.
3
- ---
4
-
5
- ```ts must
6
- const user = yield* sql`SELECT * FROM users WHERE email = ${email}`;
7
- const row = yield* Effect.promise(() => d1.prepare("SELECT * FROM users WHERE id = ?").bind(id).first());
8
- ```
9
-
10
- ```ts never
11
- const user = yield* sql.unsafe(`SELECT * FROM users WHERE email = '${email}'`);
12
- const row = yield* Effect.promise(() => d1.prepare("SELECT * FROM users WHERE id = " + id).first());
13
- ```
@@ -1,15 +0,0 @@
1
- ---
2
- description: A secret, credential, connection string, token, signed URL, or private key must never be logged, returned in an HTTP response, or returned as a stack output; code must expose only names, presence, or non-secret metadata.
3
- ---
4
-
5
- ```ts must
6
- if (url.pathname === "/secret") {
7
- return yield* HttpServerResponse.json({ name: "API_TOKEN", set: Redacted.value(token).length > 0 });
8
- }
9
- return { serial: certificate.serial, expires: certificate.notAfter }; // stack output
10
- ```
11
-
12
- ```ts never
13
- yield* Effect.log(`token=${Redacted.value(token)}`);
14
- return { databaseUrl: connection.databaseUrl, tlsKey: certificate.privateKey }; // stack output
15
- ```
@@ -1,17 +0,0 @@
1
- ---
2
- description: A call over the network, such as an HTTP request, a database query, or a third-party API, should carry a timeout and a retry schedule, and should not go out bare. A call through a client that already applies both, such as an HttpClient built with a timeout and retryTransient or an SDK with its own retries, has them. Calls through the platform FileSystem and Path services are local and are not in scope.
3
- ---
4
-
5
- ```ts should
6
- const retryPolicy = Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3)));
7
-
8
- const resilientCall = HttpClient.get("https://api.example.com/users").pipe(
9
- Effect.timeout("2 seconds"),
10
- Effect.retry(retryPolicy),
11
- Effect.timeout("10 seconds"),
12
- );
13
- ```
14
-
15
- ```ts should not
16
- const response = yield* HttpClient.get(`https://api.example.com/users/${id}`);
17
- ```