@drkmttr/adhere 0.6.1 → 0.7.1

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 (38) hide show
  1. package/README.md +77 -31
  2. package/package.json +7 -7
  3. package/presets/effect/basics/external-calls-are-resilient.md +6 -2
  4. package/presets/effect/basics/fn-for-named-effects.md +10 -2
  5. package/presets/effect/basics/gen-for-sequencing.md +10 -2
  6. package/presets/effect/basics/instrument-with-pipe.md +9 -2
  7. package/presets/effect/cli/handlers-delegate.md +13 -2
  8. package/presets/effect/config/business-logic-depends-on-config-service.md +9 -2
  9. package/presets/effect/config/secrets-are-redacted.md +6 -2
  10. package/presets/effect/config/tests-provide-values-directly.md +8 -2
  11. package/presets/effect/config/validate-with-schema.md +9 -2
  12. package/presets/effect/data/brand-meaningful-primitives.md +11 -2
  13. package/presets/effect/data/decode-json-with-schema.md +6 -2
  14. package/presets/effect/data/records-are-schema-classes.md +9 -2
  15. package/presets/effect/data/variants-are-tagged-unions.md +9 -2
  16. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  17. package/presets/effect/errors/defects-for-bugs.md +13 -2
  18. package/presets/effect/errors/domain-errors-are-tagged.md +6 -2
  19. package/presets/effect/errors/wrap-external-errors.md +7 -2
  20. package/presets/effect/platform/os-access-through-platform-services.md +8 -2
  21. package/presets/effect/services/fresh-layer-per-test.md +9 -2
  22. package/presets/effect/services/memoize-parameterized-layers.md +9 -2
  23. package/presets/effect/services/methods-have-no-requirements.md +9 -2
  24. package/presets/effect/services/no-mutable-state.md +9 -2
  25. package/presets/effect/services/provide-at-entry.md +7 -2
  26. package/presets/effect/services/test-layers-are-in-memory.md +12 -2
  27. package/presets/effect/testing/test-clock-for-time.md +11 -2
  28. package/presets/effect/testing/test-random.md +9 -2
  29. package/skills/adhere/SKILL.md +24 -17
  30. package/src/cli.ts +71 -36
  31. package/src/config.ts +72 -13
  32. package/src/init.ts +10 -8
  33. package/src/markdown.ts +59 -25
  34. package/src/services/Jev.http.ts +27 -5
  35. package/src/services/Jev.ts +45 -31
  36. package/src/services/SourceWalker.ts +47 -23
  37. package/src/workflows/audit.ts +108 -61
  38. package/src/workflows/format.ts +51 -8
@@ -1,4 +1,4 @@
1
- import type { Rule, RuleId } from "#config.ts";
1
+ import { type Examples, examplesOf, type Rule, type RuleId } from "#config.ts";
2
2
  import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
3
3
  import { AdhereConfig } from "#services/AdhereConfig.ts";
4
4
  import { AuditCache, type Judgment, sha256 } from "#services/AuditCache.ts";
@@ -18,8 +18,7 @@ import { type Crypto, Effect, Record } from "effect";
18
18
  export interface Finding {
19
19
  readonly rule: RuleId;
20
20
  readonly description: string;
21
- readonly reference?: string;
22
- readonly avoid?: string;
21
+ readonly examples: Examples;
23
22
  readonly file: string;
24
23
  readonly line: number;
25
24
  readonly snippet: string;
@@ -31,11 +30,13 @@ export interface AuditResult {
31
30
  readonly judged: number;
32
31
  readonly cached: number;
33
32
  readonly skipped: number;
33
+ /** Files whose checks `--limit` left for a later run, none judged in this one. */
34
+ readonly waiting: number;
34
35
  readonly findings: ReadonlyArray<Finding>;
35
36
  }
36
37
 
37
38
  interface FileResult {
38
- readonly status: "judged" | "cached" | "skipped";
39
+ readonly status: "judged" | "cached" | "skipped" | "waiting";
39
40
  readonly findings: ReadonlyArray<Finding>;
40
41
  }
41
42
 
@@ -70,6 +71,8 @@ export interface FilePlan {
70
71
  /** Cached judgments still valid for the file's content and the rule's text. */
71
72
  readonly kept: Readonly<Record<RuleId, Judgment>>;
72
73
  readonly pending: Readonly<Record<RuleId, PreparedRule>>;
74
+ /** Pending checks `--limit` leaves for a later run. */
75
+ readonly deferred: number;
73
76
  }
74
77
 
75
78
  /** A run worked out from the files, the rules, and the cache, before any request. */
@@ -83,6 +86,8 @@ export interface AuditPlan {
83
86
  readonly cached: number;
84
87
  /** Files too long to judge. */
85
88
  readonly skipped: number;
89
+ /** Checks `--limit` leaves for a later run. */
90
+ readonly deferred: number;
86
91
  /** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
87
92
  readonly requests: number;
88
93
  }
@@ -109,69 +114,110 @@ const inFile =
109
114
  : problem,
110
115
  );
111
116
 
112
- export const planAudit: Effect.Effect<
117
+ /**
118
+ * The plan with at most `limit` checks left to judge, taken in path order.
119
+ * Past the limit a file's other checks wait for a later run; judgments are
120
+ * cached, so a rerun picks up where this one stopped.
121
+ */
122
+ const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyArray<FilePlan> => {
123
+ let left = limit;
124
+ return planned.map((plan) => {
125
+ const ids = Object.keys(plan.pending);
126
+ const taken = new Set(ids.slice(0, left));
127
+ left -= taken.size;
128
+ return taken.size === ids.length
129
+ ? plan
130
+ : {
131
+ ...plan,
132
+ pending: Record.filter(plan.pending, (_, id) => taken.has(id)),
133
+ deferred: ids.length - taken.size,
134
+ };
135
+ });
136
+ };
137
+
138
+ export interface PlanOptions {
139
+ /** Judge at most this many checks; the rest wait for a later run. */
140
+ readonly limit?: number;
141
+ }
142
+
143
+ export const planAudit = (
144
+ options: PlanOptions = {},
145
+ ): Effect.Effect<
113
146
  AuditPlan,
114
147
  WalkUnavailable,
115
148
  AdhereConfig | AuditCache | Crypto.Crypto | SourceWalker
116
- > = Effect.gen(function* () {
117
- const config = yield* AdhereConfig;
118
- const cache = yield* AuditCache;
119
- const files = yield* (yield* SourceWalker).files;
149
+ > =>
150
+ Effect.gen(function* () {
151
+ const config = yield* AdhereConfig;
152
+ const cache = yield* AuditCache;
153
+ const files = yield* (yield* SourceWalker).files;
120
154
 
121
- const configuredRules = (file: string) =>
122
- config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
155
+ const configuredRules = (file: string) =>
156
+ config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
123
157
 
124
- const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
125
- const rules = configuredRules(file.path);
126
- // Too long for Jev's context: skipped and counted, rather than refused mid-run.
127
- if (!fits(file.lines, rules)) {
158
+ const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
159
+ const rules = configuredRules(file.path);
160
+ // Too long for Jev's context: skipped and counted, rather than refused mid-run.
161
+ if (!fits(file.lines, rules)) {
162
+ return {
163
+ file,
164
+ skipped: true,
165
+ hash: "",
166
+ prepared: {},
167
+ kept: {},
168
+ pending: {},
169
+ deferred: 0,
170
+ } satisfies FilePlan;
171
+ }
172
+ const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
173
+ Object.entries(rules),
174
+ ([id, rule]) =>
175
+ Effect.map(
176
+ fingerprintOf(config.model, rule),
177
+ (fingerprint) =>
178
+ [id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
179
+ ),
180
+ ).pipe(Effect.map(Record.fromEntries));
181
+ const hash = yield* sha256(file.lines.join("\n"));
182
+ const entry = yield* cache.get(file.path);
183
+ const remembered = entry?.hash === hash ? entry.judgments : {};
184
+ const kept = Record.filter(
185
+ remembered,
186
+ (judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
187
+ );
188
+ const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
128
189
  return {
129
190
  file,
130
- skipped: true,
131
- hash: "",
132
- prepared: {},
133
- kept: {},
134
- pending: {},
191
+ skipped: false,
192
+ hash,
193
+ prepared,
194
+ kept,
195
+ pending,
196
+ deferred: 0,
135
197
  } satisfies FilePlan;
136
- }
137
- const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
138
- Object.entries(rules),
139
- ([id, rule]) =>
140
- Effect.map(
141
- fingerprintOf(config.model, rule),
142
- (fingerprint) =>
143
- [id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
144
- ),
145
- ).pipe(Effect.map(Record.fromEntries));
146
- const hash = yield* sha256(file.lines.join("\n"));
147
- const entry = yield* cache.get(file.path);
148
- const remembered = entry?.hash === hash ? entry.judgments : {};
149
- const kept = Record.filter(
150
- remembered,
151
- (judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
152
- );
153
- const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
154
- return { file, skipped: false, hash, prepared, kept, pending } satisfies FilePlan;
155
- });
198
+ });
156
199
 
157
- const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
158
- const planned: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
159
- concurrency: 8,
200
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
201
+ const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
202
+ concurrency: 8,
203
+ });
204
+ const planned =
205
+ options.limit === undefined ? everything : withinLimit(everything, options.limit);
206
+ const judged = planned.filter((plan) => !plan.skipped);
207
+ const total = (count: (plan: FilePlan) => number) =>
208
+ judged.reduce((sum, plan) => sum + count(plan), 0);
209
+ return {
210
+ files: planned,
211
+ rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
212
+ checks: total((plan) => sizeOf(plan.prepared)),
213
+ cached: total((plan) => sizeOf(plan.kept)),
214
+ skipped: planned.length - judged.length,
215
+ deferred: total((plan) => plan.deferred),
216
+ requests: total((plan) =>
217
+ isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
218
+ ),
219
+ };
160
220
  });
161
- const judged = planned.filter((plan) => !plan.skipped);
162
- const total = (count: (plan: FilePlan) => number) =>
163
- judged.reduce((sum, plan) => sum + count(plan), 0);
164
- return {
165
- files: planned,
166
- rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
167
- checks: total((plan) => sizeOf(plan.prepared)),
168
- cached: total((plan) => sizeOf(plan.kept)),
169
- skipped: planned.length - judged.length,
170
- requests: total((plan) =>
171
- isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
172
- ),
173
- };
174
- });
175
221
 
176
222
  /**
177
223
  * Judges what the plan left pending and locates the findings, a file at a
@@ -192,6 +238,7 @@ export const executeAudit = (
192
238
  prepared,
193
239
  kept,
194
240
  pending,
241
+ deferred,
195
242
  }: FilePlan) {
196
243
  const probabilities = isEmpty(pending)
197
244
  ? {}
@@ -233,8 +280,7 @@ export const executeAudit = (
233
280
  {
234
281
  rule: id,
235
282
  description: k.rule.description,
236
- reference: k.rule.reference,
237
- avoid: k.rule.avoid,
283
+ examples: examplesOf(k.rule),
238
284
  file: file.path,
239
285
  line: judgment.line,
240
286
  snippet: judgment.snippet ?? "",
@@ -245,7 +291,7 @@ export const executeAudit = (
245
291
  })
246
292
  .sort((a, b) => a.line - b.line);
247
293
  return {
248
- result: fileResult(cached ? "cached" : "judged", findings),
294
+ result: fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings),
249
295
  requests:
250
296
  (isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
251
297
  (isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
@@ -276,6 +322,7 @@ export const executeAudit = (
276
322
  judged: count("judged"),
277
323
  cached: count("cached"),
278
324
  skipped: count("skipped"),
325
+ waiting: count("waiting"),
279
326
  findings: results.flatMap((result) => result.findings),
280
327
  };
281
328
  });
@@ -285,6 +332,6 @@ export const runAudit: Effect.Effect<
285
332
  AuditResult,
286
333
  WalkUnavailable | JevUnavailable,
287
334
  AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
288
- > = Effect.flatMap(planAudit, (plan) => executeAudit(plan));
335
+ > = Effect.flatMap(planAudit(), (plan) => executeAudit(plan));
289
336
 
290
337
  export { type RenderOptions, render } from "#workflows/format.ts";
@@ -97,16 +97,20 @@ const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line
97
97
  ];
98
98
  };
99
99
 
100
- /** The reference as the hint. A rule without one shows its code to avoid instead, labeled so. */
100
+ /**
101
+ * The code to write as the hint. A rule with only code not to write shows
102
+ * that instead, labeled with its word: `never`, or `should not`.
103
+ */
101
104
  const hint = (finding: Finding): ReadonlyArray<Line> => {
105
+ const { good, bad } = finding.examples;
102
106
  const [label, code] =
103
- finding.reference === undefined ? ["avoid", finding.avoid ?? ""] : ["hint", finding.reference];
107
+ good === undefined ? [bad?.word ?? "hint", bad?.code ?? ""] : ["hint", good.code];
104
108
  return hanging(span(` ${label}: `, "label"), codeLines(code));
105
109
  };
106
110
 
107
111
  /**
108
112
  * One diagnostic, in the frame `vp lint` prints on a terminal: a red header,
109
- * the offending line, a pink underline, and the reference code as the hint.
113
+ * the offending line, a pink underline, and the code to write as the hint.
110
114
  */
111
115
  const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
112
116
  header(finding),
@@ -120,6 +124,7 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
120
124
  `${result.judged} judged`,
121
125
  `${result.cached} cached`,
122
126
  ...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
127
+ ...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
123
128
  ];
124
129
  return [
125
130
  [span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
@@ -149,18 +154,56 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
149
154
  * between them and how many the cache answers, then what judging the rest
150
155
  * takes.
151
156
  */
152
- export const describePlan = (plan: AuditPlan): ReadonlyArray<string> => {
157
+ /** What a run was asked to keep to, for the plan to say. */
158
+ export interface RunLimits {
159
+ readonly filter?: ReadonlyArray<string>;
160
+ readonly rpm?: number;
161
+ }
162
+
163
+ /** About how long `requests` take at `rpm`: seconds under a minute, minutes past it. */
164
+ const durationAt = (requests: number, rpm: number): string => {
165
+ const seconds = Math.ceil((requests / rpm) * 60);
166
+ return seconds < 60
167
+ ? counted(seconds, "second", "seconds")
168
+ : counted(Math.round(seconds / 60), "minute", "minutes");
169
+ };
170
+
171
+ export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyArray<string> => {
153
172
  const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
154
173
  const skipped =
155
174
  plan.skipped > 0 ? `, ${counted(plan.skipped, "file", "files")} too long to judge` : "";
156
- const found = `${counted(plan.files.length, "file", "files")} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
175
+ const matching = (limits.filter?.length ?? 0) > 0 ? " matching the filter" : "";
176
+ const found = `${counted(plan.files.length, "file", "files")}${matching} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
177
+ const pending = plan.checks - plan.cached;
178
+ const waiting =
179
+ plan.deferred > 0
180
+ ? ` The other ${plan.deferred} ${plan.deferred === 1 ? "waits" : "wait"} for a later run.`
181
+ : "";
157
182
  if (plan.requests === 0) {
158
- return [found, plan.checks === 0 ? "Nothing to judge." : "The cache answers every check."];
183
+ const none =
184
+ plan.deferred > 0
185
+ ? plan.deferred === 1
186
+ ? "The limit leaves the 1 unjudged check for a later run."
187
+ : `The limit leaves all ${plan.deferred} unjudged checks for a later run.`
188
+ : plan.checks === 0
189
+ ? "Nothing to judge."
190
+ : "The cache answers every check.";
191
+ return [found, none];
159
192
  }
160
- const rest = plan.cached > 0 ? `the other ${plan.checks - plan.cached}` : "them";
193
+ const judging = pending - plan.deferred;
194
+ const rest =
195
+ plan.deferred > 0
196
+ ? `${judging} of ${plan.cached > 0 ? `the other ${pending}` : "them"}`
197
+ : plan.cached > 0
198
+ ? `the other ${pending}`
199
+ : "them";
200
+ const pace =
201
+ limits.rpm === undefined
202
+ ? ""
203
+ : ` At ${limits.rpm} a minute, they take about ${durationAt(plan.requests, limits.rpm)}.`;
161
204
  return [
162
205
  found,
163
- `Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.`,
206
+ `Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.${pace}${waiting}`,
164
207
  ];
165
208
  };
166
209