@drkmttr/adhere 0.10.0 → 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 (51) hide show
  1. package/README.md +177 -41
  2. package/package.json +7 -7
  3. package/presets/effect/basics/gen-for-sequencing.md +1 -1
  4. package/presets/effect/data/brand-meaningful-primitives.md +1 -1
  5. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  6. package/presets/effect/services/dependencies-through-layers.md +1 -1
  7. package/presets/react/effects/clean-up-what-they-start.md +23 -0
  8. package/presets/react/effects/event-logic-in-handlers.md +29 -0
  9. package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
  10. package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
  11. package/presets/react/effects/notify-parents-in-handlers.md +29 -0
  12. package/presets/react/server/no-private-data-to-client.md +23 -0
  13. package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
  14. package/presets/react/state/no-contradictory-state.md +17 -0
  15. package/presets/react/state/no-props-copied-into-state.md +23 -0
  16. package/presets/react/state/store-ids-not-copies.md +20 -0
  17. package/presets/security/constant-time-secret-compare.md +22 -0
  18. package/presets/security/no-secrets-in-output.md +19 -0
  19. package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
  20. package/presets/security/parameterized-queries.md +16 -0
  21. package/presets/typescript/async/concurrent-independent-work.md +23 -0
  22. package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
  23. package/presets/typescript/design/no-argument-mutation.md +20 -0
  24. package/presets/typescript/errors/no-swallowed-errors.md +29 -0
  25. package/presets/typescript/errors/release-on-every-path.md +29 -0
  26. package/presets/typescript/testing/assert-outcomes.md +22 -0
  27. package/presets/typescript/testing/independent-tests.md +41 -0
  28. package/presets/typescript/types/assertions-only-when-proven.md +20 -0
  29. package/presets/typescript/types/derive-dont-restate.md +24 -0
  30. package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
  31. package/presets/typescript/types/validate-external-data.md +21 -0
  32. package/skills/adhere/SKILL.md +4 -3
  33. package/skills/adhere-fix/SKILL.md +10 -4
  34. package/src/cli.ts +9 -1
  35. package/src/config.ts +29 -1
  36. package/src/excerpt.ts +72 -62
  37. package/src/highlight.ts +2 -0
  38. package/src/markdown.ts +6 -0
  39. package/src/presets.ts +6 -0
  40. package/src/rules.ts +1 -1
  41. package/src/services/AuditCache.ts +31 -5
  42. package/src/services/Jev.http.ts +76 -11
  43. package/src/services/Jev.ts +191 -73
  44. package/src/services/SourceWalker.ts +23 -7
  45. package/src/suppress.ts +13 -7
  46. package/src/version.ts +25 -1
  47. package/src/workflows/audit.ts +96 -26
  48. package/src/workflows/format.ts +51 -11
  49. package/presets/alchemy/data/parameterized-sql.md +0 -17
  50. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
  51. package/presets/effect/basics/external-calls-are-resilient.md +0 -21
@@ -1,6 +1,6 @@
1
1
  import { isNote, withoutComments } from "#comments.ts";
2
2
  import { type Examples, examplesOf, type Level, type Rule, type RuleId } from "#config.ts";
3
- import { type Excerpt, excerptOf } from "#excerpt.ts";
3
+ import { type Excerpt, type Range } from "#excerpt.ts";
4
4
  import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
5
5
  import { AdhereConfig } from "#services/AdhereConfig.ts";
6
6
  import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
@@ -13,7 +13,9 @@ import {
13
13
  judgeLoad,
14
14
  judgeRequests,
15
15
  linterQuestion,
16
+ type Located,
16
17
  locateRequests,
18
+ matcherQuestions,
17
19
  questionRoom,
18
20
  tokensOf,
19
21
  } from "#services/Jev.ts";
@@ -32,9 +34,18 @@ export interface Finding {
32
34
  readonly description: string;
33
35
  readonly examples: Examples;
34
36
  readonly file: string;
35
- readonly line: number;
36
- /** 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. */
37
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;
38
49
  readonly probability: number;
39
50
  /** An error fails the run; a warning does not, unless `--deny-warnings`. */
40
51
  readonly level: Level;
@@ -90,13 +101,51 @@ const fileResult = (
90
101
 
91
102
  const isEmpty = Record.isEmptyReadonlyRecord;
92
103
 
104
+ /** How Jev reads a file, which a judgment depends on: a new layout re-judges every rule. */
105
+ const LAYOUT = "sections";
106
+
93
107
  /**
94
- * What a judgment depends on besides the file: the model, and the question
95
- * asked for the rule, which carries the rule. An edited rule re-judges
96
- * 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.
97
113
  */
98
- const fingerprintOf = (model: string, rule: Rule) =>
99
- 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(", ");
100
149
 
101
150
  /** One file before anything is sent: its rules, and which of them the cache answers. */
102
151
  export interface FilePlan {
@@ -143,6 +192,8 @@ export interface AuditPlan {
143
192
  readonly tokens: number;
144
193
  /** The model's price per million input tokens, in dollars, when adhere knows it. */
145
194
  readonly price?: number;
195
+ /** Below this, a finding's sufficiency adds a warning to it. */
196
+ readonly sufficiencyThreshold: number;
146
197
  }
147
198
 
148
199
  /** A file finished, for a progress counter: the requests it took and the findings it has. */
@@ -408,6 +459,7 @@ export const planAudit = (
408
459
  requests: loads.reduce((sum, load) => sum + load.requests, 0),
409
460
  tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
410
461
  ...(price === undefined ? {} : { price }),
462
+ sufficiencyThreshold: config.sufficiencyThreshold,
411
463
  };
412
464
  });
413
465
 
@@ -457,14 +509,25 @@ export const executeAudit = (
457
509
  deferred,
458
510
  sampled,
459
511
  }: FilePlan) {
460
- const { probabilities, linter }: Judged = isEmpty(pending)
512
+ const { probabilities, linter, matchers }: Judged = isEmpty(pending)
461
513
  ? { probabilities: {}, linter: {} }
462
514
  : yield* jev.judge(file.lines, rulesOf(pending), Object.keys(sampled)).pipe(inFile(file));
463
515
  if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
464
516
  const judged: Record<RuleId, Judgment> = { ...kept };
465
517
  for (const [id, probability] of Object.entries(probabilities)) {
466
518
  const k = pending[id];
467
- 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
+ }
468
531
  }
469
532
 
470
533
  const flagged = Record.filter(prepared, (k, id) => {
@@ -472,29 +535,30 @@ export const executeAudit = (
472
535
  return (
473
536
  judgment !== undefined &&
474
537
  judgment.probability > k.threshold &&
475
- judgment.line === undefined
538
+ inScope(judgment) &&
539
+ (judgment.section === undefined || judgment.sufficiency === undefined)
476
540
  );
477
541
  });
478
- // A blocked line question still leaves the judgments worth caching: a rerun
479
- // 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.
480
544
  const {
481
- lines,
545
+ places,
482
546
  blocked,
483
547
  }: {
484
- readonly lines: Readonly<Record<RuleId, number>>;
548
+ readonly places: Readonly<Record<RuleId, Located>>;
485
549
  readonly blocked: Blocked | undefined;
486
550
  } = isEmpty(flagged)
487
- ? { lines: {}, blocked: undefined }
551
+ ? { places: {}, blocked: undefined }
488
552
  : yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
489
- Effect.map((lines) => ({ lines, blocked: undefined })),
553
+ Effect.map((places) => ({ places, blocked: undefined })),
490
554
  Effect.catchTag("JevBlocked", ({ ray }) =>
491
- Effect.succeed({ lines: {}, blocked: { file: file.path, ray } }),
555
+ Effect.succeed({ places: {}, blocked: { file: file.path, ray } }),
492
556
  ),
493
557
  inFile(file),
494
558
  );
495
559
  const located = Record.map(judged, (judgment, id) => {
496
- const line = lines[id];
497
- return line === undefined ? judgment : { ...judgment, line };
560
+ const place = places[id];
561
+ return place === undefined ? judgment : { ...judgment, ...place };
498
562
  });
499
563
 
500
564
  const cached = isEmpty(pending) && isEmpty(flagged);
@@ -504,8 +568,9 @@ export const executeAudit = (
504
568
  .flatMap(([id, judgment]): ReadonlyArray<Finding> => {
505
569
  const k = prepared[id];
506
570
  return k !== undefined &&
507
- judgment.line !== undefined &&
508
- judgment.probability > k.threshold
571
+ judgment.section !== undefined &&
572
+ judgment.probability > k.threshold &&
573
+ inScope(judgment)
509
574
  ? [
510
575
  {
511
576
  rule: id,
@@ -513,15 +578,20 @@ export const executeAudit = (
513
578
  description: k.rule.description,
514
579
  examples: examplesOf(k.rule),
515
580
  file: file.path,
516
- line: judgment.line,
517
- excerpt: excerptOf(original, 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
+ : {}),
518
588
  probability: judgment.probability,
519
589
  level: k.rule.level ?? "error",
520
590
  },
521
591
  ]
522
592
  : [];
523
593
  })
524
- .sort((a, b) => a.line - b.line);
594
+ .sort((a, b) => (a.lines ?? a.section).first - (b.lines ?? b.section).first);
525
595
  return {
526
596
  result:
527
597
  blocked === undefined
@@ -583,7 +653,7 @@ export const executeAudit = (
583
653
  !suppresses(
584
654
  plan.suppressions,
585
655
  shownId({ id: finding.rule, preset: finding.preset }),
586
- finding.line,
656
+ finding.lines ?? finding.section,
587
657
  ),
588
658
  );
589
659
  if (shown.length < result.findings.length) {
@@ -102,36 +102,74 @@ const header = (finding: Finding): Line => [
102
102
  ];
103
103
 
104
104
  /**
105
- * The path, then the finding's excerpt with each line numbered in the gutter,
106
- * 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.
107
110
  */
108
111
  const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => {
109
112
  const { start, lines } = finding.excerpt;
110
113
  const width = String(start + lines.length - 1).length;
111
114
  const gutter = span(" ".repeat(width + 2));
112
- const offending = lines[finding.line - start] ?? "";
113
- // The line's own indentation, tabs and all, lines the underline up under its code.
114
- const indent = offending.slice(0, offending.length - offending.trimStart().length);
115
- const underline = "─".repeat(Math.max(1, offending.trim().length));
116
- 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;
117
122
  return [
118
123
  [gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(location)],
119
124
  ...highlighted(lines.join("\n")).flatMap((code, index): ReadonlyArray<Line> => {
120
125
  const number = String(start + index);
126
+ const marker = spanning ? [inside(start + index) ? span("┃ ", "underline") : span(" ")] : [];
121
127
  const numbered = [
122
128
  span(" ".repeat(width - number.length + 1)),
123
129
  span(number, "lineNumber"),
124
130
  span(" │ "),
131
+ ...marker,
125
132
  ...code,
126
133
  ];
127
- return start + index === finding.line
128
- ? [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
+ ]
129
146
  : [numbered];
130
147
  }),
131
148
  [gutter, span("╰────")],
132
149
  ];
133
150
  };
134
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
+
135
173
  /**
136
174
  * The code to write as the hint. A rule with only code not to write shows
137
175
  * that instead, labeled with its word: `never`, or `should not`.
@@ -145,12 +183,14 @@ const hint = (finding: Finding): ReadonlyArray<Line> => {
145
183
 
146
184
  /**
147
185
  * One diagnostic, in the frame `vp lint` prints on a terminal: a header with
148
- * the rule in red, the code around the offending line, a pink underline under
149
- * 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.
150
189
  */
151
190
  const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
152
191
  header(finding),
153
192
  ...excerpt(finding, root),
193
+ ...insufficient(finding),
154
194
  ...hint(finding),
155
195
  ];
156
196
 
@@ -1,17 +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
- ## Must
6
-
7
- ```ts
8
- const user = yield* sql`SELECT * FROM users WHERE email = ${email}`;
9
- const row = yield* Effect.promise(() => d1.prepare("SELECT * FROM users WHERE id = ?").bind(id).first());
10
- ```
11
-
12
- ## Never
13
-
14
- ```ts
15
- const user = yield* sql.unsafe(`SELECT * FROM users WHERE email = '${email}'`);
16
- const row = yield* Effect.promise(() => d1.prepare("SELECT * FROM users WHERE id = " + id).first());
17
- ```
@@ -1,19 +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
- ## Must
6
-
7
- ```ts
8
- if (url.pathname === "/secret") {
9
- return yield* HttpServerResponse.json({ name: "API_TOKEN", set: Redacted.value(token).length > 0 });
10
- }
11
- return { serial: certificate.serial, expires: certificate.notAfter }; // stack output
12
- ```
13
-
14
- ## Never
15
-
16
- ```ts
17
- yield* Effect.log(`token=${Redacted.value(token)}`);
18
- return { databaseUrl: connection.databaseUrl, tlsKey: certificate.privateKey }; // stack output
19
- ```
@@ -1,21 +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
- ## Should
6
-
7
- ```ts
8
- const retryPolicy = Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3)));
9
-
10
- const resilientCall = HttpClient.get("https://api.example.com/users").pipe(
11
- Effect.timeout("2 seconds"),
12
- Effect.retry(retryPolicy),
13
- Effect.timeout("10 seconds"),
14
- );
15
- ```
16
-
17
- ## Should not
18
-
19
- ```ts
20
- const response = yield* HttpClient.get(`https://api.example.com/users/${id}`);
21
- ```