@drkmttr/adhere 0.7.3 → 0.9.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 (80) hide show
  1. package/README.md +177 -37
  2. package/package.json +12 -12
  3. package/presets/alchemy/apis/match-remote-errors-by-tag.md +15 -0
  4. package/presets/alchemy/apis/schemas-at-trust-boundaries.md +18 -0
  5. package/presets/alchemy/data/commit-migrations-then-deploy.md +15 -0
  6. package/presets/alchemy/data/hyperdrive-direct-origin.md +14 -0
  7. package/presets/alchemy/data/migrations-use-direct-url.md +11 -0
  8. package/presets/alchemy/data/never-disable-tls-verification.md +16 -0
  9. package/presets/alchemy/data/parameterized-sql.md +13 -0
  10. package/presets/alchemy/data/private-endpoint-at-runtime.md +12 -0
  11. package/presets/alchemy/data/single-migration-owner.md +13 -0
  12. package/presets/alchemy/durable/idempotent-event-handlers.md +15 -0
  13. package/presets/alchemy/durable/io-inside-steps.md +17 -0
  14. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +15 -0
  15. package/presets/alchemy/durable/stable-step-names.md +11 -0
  16. package/presets/alchemy/durable/webhook-state-is-truth.md +14 -0
  17. package/presets/alchemy/providers/diff-guards-unresolved-props.md +16 -0
  18. package/presets/alchemy/providers/idempotent-delete.md +14 -0
  19. package/presets/alchemy/providers/lazy-layer-construction.md +14 -0
  20. package/presets/alchemy/providers/reconcile-from-observed-state.md +20 -0
  21. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +14 -0
  22. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +19 -0
  23. package/presets/alchemy/resources/previews-reference-shared-resources.md +15 -0
  24. package/presets/alchemy/resources/protect-irreplaceable-data.md +12 -0
  25. package/presets/alchemy/resources/remote-state-for-ci.md +22 -0
  26. package/presets/alchemy/resources/stable-logical-ids.md +13 -0
  27. package/presets/alchemy/resources/stage-unique-physical-names.md +15 -0
  28. package/presets/alchemy/runtime/bind-tag-not-implementation.md +27 -0
  29. package/presets/alchemy/runtime/deterministic-config-defaults.md +13 -0
  30. package/presets/alchemy/runtime/disposables-in-request-scope.md +27 -0
  31. package/presets/alchemy/runtime/lambda-no-post-response-work.md +19 -0
  32. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +24 -0
  33. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +31 -0
  34. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +13 -0
  35. package/presets/alchemy/secrets/never-logged-returned-or-output.md +15 -0
  36. package/presets/alchemy/secrets/none-in-browser-env.md +15 -0
  37. package/presets/alchemy/secrets/redacted-until-use.md +19 -0
  38. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +17 -0
  39. package/presets/alchemy/security/authorize-public-endpoints.md +20 -0
  40. package/presets/alchemy/security/github-secret-not-variable.md +12 -0
  41. package/presets/alchemy/security/identity-from-verified-session.md +14 -0
  42. package/presets/alchemy/security/keep-auth-protections-on.md +19 -0
  43. package/presets/alchemy/security/least-privilege-bindings.md +13 -0
  44. package/presets/alchemy/security/least-privilege-ci-credentials.md +24 -0
  45. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +15 -0
  46. package/presets/effect/basics/external-calls-are-resilient.md +3 -3
  47. package/presets/effect/config/tests-provide-values-directly.md +3 -3
  48. package/presets/effect/config/validate-with-schema.md +13 -2
  49. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  50. package/presets/effect/services/dependencies-through-layers.md +42 -0
  51. package/presets/effect/services/operations-have-no-requirements.md +46 -0
  52. package/presets/effect/services/test-layers-are-in-memory.md +3 -3
  53. package/presets/effect/testing/test-clock-for-time.md +1 -1
  54. package/skills/adhere/SKILL.md +3 -2
  55. package/src/cli.ts +42 -16
  56. package/src/config.ts +12 -2
  57. package/src/excerpt.ts +255 -0
  58. package/src/highlight.ts +82 -20
  59. package/src/init.ts +10 -4
  60. package/src/main.ts +35 -4
  61. package/src/mechanical.ts +89 -0
  62. package/src/presets.ts +45 -8
  63. package/src/rules.ts +2 -0
  64. package/src/services/AdhereConfig.ts +18 -20
  65. package/src/services/AuditCache.ts +272 -23
  66. package/src/services/Jev.http.ts +66 -12
  67. package/src/services/Jev.ts +66 -6
  68. package/src/services/SourceWalker.ts +11 -4
  69. package/src/wording.ts +90 -0
  70. package/src/workflows/audit.ts +169 -26
  71. package/src/workflows/format.ts +35 -18
  72. package/presets/effect/basics/fn-for-named-effects.md +0 -19
  73. package/presets/effect/cli/handlers-delegate.md +0 -24
  74. package/presets/effect/data/decode-json-with-schema.md +0 -18
  75. package/presets/effect/data/records-are-schema-classes.md +0 -23
  76. package/presets/effect/errors/domain-errors-are-tagged.md +0 -19
  77. package/presets/effect/platform/os-access-through-platform-services.md +0 -26
  78. package/presets/effect/services/methods-have-no-requirements.md +0 -20
  79. package/presets/effect/services/provide-at-entry.md +0 -23
  80. package/presets/effect/testing/test-random.md +0 -22
@@ -1,19 +1,24 @@
1
1
  import { type Examples, examplesOf, type Rule, type RuleId } from "#config.ts";
2
+ import { type Excerpt, excerptOf } from "#excerpt.ts";
2
3
  import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
3
4
  import { AdhereConfig } from "#services/AdhereConfig.ts";
4
- import { AuditCache, type Judgment, sha256 } from "#services/AuditCache.ts";
5
+ import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
5
6
  import {
6
7
  fits,
7
8
  Jev,
8
9
  JevUnavailable,
10
+ type Judged,
9
11
  judgeQuestion,
10
12
  judgeRequests,
13
+ linterQuestion,
11
14
  locateRequests,
12
- snippetOf,
15
+ questionRoom,
16
+ tokensOf,
13
17
  } from "#services/Jev.ts";
14
18
  import { SourceWalker } from "#services/SourceWalker.ts";
19
+ import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
15
20
  import { applicableRules } from "#rules.ts";
16
- import { type Crypto, Effect, Record } from "effect";
21
+ import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
17
22
 
18
23
  export interface Finding {
19
24
  readonly rule: RuleId;
@@ -21,7 +26,8 @@ export interface Finding {
21
26
  readonly examples: Examples;
22
27
  readonly file: string;
23
28
  readonly line: number;
24
- readonly snippet: string;
29
+ /** The code around `line` the report shows. */
30
+ readonly excerpt: Excerpt;
25
31
  readonly probability: number;
26
32
  }
27
33
 
@@ -82,6 +88,8 @@ export interface FilePlan {
82
88
  readonly pending: Readonly<Record<RuleId, PreparedRule>>;
83
89
  /** Pending checks `--limit` leaves for a later run. */
84
90
  readonly deferred: number;
91
+ /** Pending rules whose linter check question rides along on this file, each with its tally's key. */
92
+ readonly sampled: Readonly<Record<RuleId, string>>;
85
93
  }
86
94
 
87
95
  /** A run worked out from the files, the rules, and the cache, before any request. */
@@ -168,6 +176,7 @@ export const planAudit = (
168
176
  const rules = configuredRules(file.path);
169
177
  // Too long for Jev's context: skipped and counted, rather than refused mid-run.
170
178
  if (!fits(file.lines, rules)) {
179
+ yield* Effect.logTrace(`plan ${file.path}: too long for Jev's context, skipped`);
171
180
  return {
172
181
  file,
173
182
  skipped: true,
@@ -176,6 +185,7 @@ export const planAudit = (
176
185
  kept: {},
177
186
  pending: {},
178
187
  deferred: 0,
188
+ sampled: {},
179
189
  } satisfies FilePlan;
180
190
  }
181
191
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
@@ -188,13 +198,15 @@ export const planAudit = (
188
198
  ),
189
199
  ).pipe(Effect.map(Record.fromEntries));
190
200
  const hash = yield* sha256(file.lines.join("\n"));
191
- const entry = yield* cache.get(file.path);
192
- const remembered = entry?.hash === hash ? entry.judgments : {};
193
- const kept = Record.filter(
194
- remembered,
195
- (judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
196
- );
201
+ const answers = yield* cache.get(hash, file.path);
202
+ const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
203
+ const answer = answers[fingerprint];
204
+ return answer === undefined ? Result.failVoid : Result.succeed({ fingerprint, ...answer });
205
+ });
197
206
  const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
207
+ yield* Effect.logTrace(
208
+ `plan ${file.path}: ${sizeOf(prepared)} rules, ${sizeOf(kept)} cached, ${sizeOf(pending)} to judge`,
209
+ );
198
210
  return {
199
211
  file,
200
212
  skipped: false,
@@ -203,15 +215,78 @@ export const planAudit = (
203
215
  kept,
204
216
  pending,
205
217
  deferred: 0,
218
+ sampled: {},
206
219
  } satisfies FilePlan;
207
220
  });
208
221
 
222
+ // Preset rules are not the project's to change, so the linter check leaves them out.
223
+ const presetRules = new Set(
224
+ (config.scopedRules ?? []).flatMap((entry) =>
225
+ entry.preset === undefined ? [] : [entry.rule],
226
+ ),
227
+ );
228
+
229
+ /**
230
+ * The linter check's samples: for each project rule its tally still needs
231
+ * answers for, files this run judges it on, spread evenly over them in path
232
+ * order, skipping files the tally has and files its question would not fit.
233
+ */
234
+ const withSamples = Effect.fn("audit.samples")(function* (plans: ReadonlyArray<FilePlan>) {
235
+ const needs = new Map<Rule, { readonly key: string; readonly tallied: Tally["files"] }>();
236
+ for (const plan of plans) {
237
+ for (const { rule } of Object.values(plan.pending)) {
238
+ if (presetRules.has(rule) || needs.has(rule)) continue;
239
+ const key = yield* tallyKeyOf(config.model, rule);
240
+ needs.set(rule, { key, tallied: (yield* cache.tally(key))?.files ?? {} });
241
+ }
242
+ }
243
+ for (const [rule, { tallied }] of needs) {
244
+ if (Object.keys(tallied).length >= SAMPLE) needs.delete(rule);
245
+ }
246
+ if (needs.size === 0) return plans;
247
+
248
+ const candidates = new Map<Rule, Array<{ readonly plan: FilePlan; readonly id: RuleId }>>();
249
+ const tokens = new Map<Rule, number>();
250
+ for (const plan of plans) {
251
+ let room: number | undefined;
252
+ for (const [id, { rule }] of Object.entries(plan.pending)) {
253
+ const need = needs.get(rule);
254
+ if (need === undefined || need.tallied[plan.file.path] !== undefined) continue;
255
+ room ??= questionRoom(plan.file.lines);
256
+ const size = tokens.get(rule) ?? tokensOf(linterQuestion(rule));
257
+ tokens.set(rule, size);
258
+ if (size > room) continue;
259
+ const found = candidates.get(rule);
260
+ if (found === undefined) candidates.set(rule, [{ plan, id }]);
261
+ else found.push({ plan, id });
262
+ }
263
+ }
264
+
265
+ const sampled = new Map<FilePlan, Record<RuleId, string>>();
266
+ for (const [rule, found] of candidates) {
267
+ const need = needs.get(rule);
268
+ if (need === undefined) continue;
269
+ const count = Math.min(found.length, SAMPLE - Object.keys(need.tallied).length);
270
+ for (let index = 0; index < count; index++) {
271
+ const pick = found[Math.floor((index * found.length) / count)];
272
+ if (pick !== undefined) {
273
+ sampled.set(pick.plan, { ...sampled.get(pick.plan), [pick.id]: need.key });
274
+ }
275
+ }
276
+ }
277
+ return plans.map((plan) => {
278
+ const some = sampled.get(plan);
279
+ return some === undefined ? plan : { ...plan, sampled: some };
280
+ });
281
+ });
282
+
209
283
  const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
210
284
  const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
211
285
  concurrency: 8,
212
286
  });
213
- const planned =
287
+ const limited =
214
288
  options.limit === undefined ? everything : withinLimit(everything, options.limit);
289
+ const planned = yield* withSamples(limited);
215
290
  const judged = planned.filter((plan) => !plan.skipped);
216
291
  const total = (count: (plan: FilePlan) => number) =>
217
292
  judged.reduce((sum, plan) => sum + count(plan), 0);
@@ -223,7 +298,9 @@ export const planAudit = (
223
298
  skipped: planned.length - judged.length,
224
299
  deferred: total((plan) => plan.deferred),
225
300
  requests: total((plan) =>
226
- isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
301
+ isEmpty(plan.pending)
302
+ ? 0
303
+ : judgeRequests(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
227
304
  ),
228
305
  };
229
306
  });
@@ -239,6 +316,29 @@ export const executeAudit = (
239
316
  Effect.gen(function* () {
240
317
  const jev = yield* Jev;
241
318
  const cache = yield* AuditCache;
319
+ // Files finish together; each tally is read, extended, and written back by one file at a time.
320
+ const tallying = yield* Semaphore.make(1);
321
+
322
+ /** Adds one file's linter check answers to their rules' tallies. */
323
+ const record = (
324
+ file: string,
325
+ sampled: Readonly<Record<RuleId, string>>,
326
+ linter: Readonly<Record<RuleId, number>>,
327
+ ) =>
328
+ tallying.withPermits(1)(
329
+ Effect.forEach(
330
+ Object.entries(linter),
331
+ ([id, probability]) => {
332
+ const key = sampled[id];
333
+ return key === undefined
334
+ ? Effect.void
335
+ : Effect.flatMap(cache.tally(key), (tally) =>
336
+ cache.putTally(key, { files: { ...tally?.files, [file]: probability } }),
337
+ );
338
+ },
339
+ { discard: true },
340
+ ),
341
+ );
242
342
 
243
343
  /** Judges and locates one file the plan left pending, with the requests that took. */
244
344
  const judgeFile = Effect.fn("audit.judgeFile")(function* ({
@@ -248,10 +348,12 @@ export const executeAudit = (
248
348
  kept,
249
349
  pending,
250
350
  deferred,
351
+ sampled,
251
352
  }: FilePlan) {
252
- const probabilities = isEmpty(pending)
253
- ? {}
254
- : yield* jev.judge(file.lines, rulesOf(pending)).pipe(inFile(file));
353
+ const { probabilities, linter }: Judged = isEmpty(pending)
354
+ ? { probabilities: {}, linter: {} }
355
+ : yield* jev.judge(file.lines, rulesOf(pending), Object.keys(sampled)).pipe(inFile(file));
356
+ if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
255
357
  const judged: Record<RuleId, Judgment> = { ...kept };
256
358
  for (const [id, probability] of Object.entries(probabilities)) {
257
359
  const k = pending[id];
@@ -284,13 +386,11 @@ export const executeAudit = (
284
386
  );
285
387
  const located = Record.map(judged, (judgment, id) => {
286
388
  const line = lines[id];
287
- return line === undefined
288
- ? judgment
289
- : { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
389
+ return line === undefined ? judgment : { ...judgment, line };
290
390
  });
291
391
 
292
392
  const cached = isEmpty(pending) && isEmpty(flagged);
293
- if (!cached) yield* cache.put(file.path, { hash, judgments: located });
393
+ if (!cached) yield* cache.put(hash, answersOf(located));
294
394
 
295
395
  const findings = Object.entries(located)
296
396
  .flatMap(([id, judgment]): ReadonlyArray<Finding> => {
@@ -305,7 +405,7 @@ export const executeAudit = (
305
405
  examples: examplesOf(k.rule),
306
406
  file: file.path,
307
407
  line: judgment.line,
308
- snippet: judgment.snippet ?? "",
408
+ excerpt: excerptOf(file.lines, judgment.line),
309
409
  probability: judgment.probability,
310
410
  },
311
411
  ]
@@ -318,20 +418,26 @@ export const executeAudit = (
318
418
  ? fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings)
319
419
  : { status: "blocked", findings, blocked },
320
420
  requests:
321
- (isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
421
+ (isEmpty(pending)
422
+ ? 0
423
+ : judgeRequests(file.lines, rulesOf(pending), Object.keys(sampled))) +
322
424
  (isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
323
425
  };
324
426
  });
325
427
 
326
428
  const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
327
- const { result, requests } = plan.skipped
328
- ? { result: fileResult("skipped", []), requests: 0 }
329
- : yield* judgeFile(plan).pipe(
429
+ const judging = plan.skipped
430
+ ? Effect.succeed({ result: fileResult("skipped", []), requests: 0 })
431
+ : judgeFile(plan).pipe(
330
432
  // Jev counted more than `fits` estimated. Its count decides: skipped the same way.
331
433
  Effect.catchTag("JevOverflow", () =>
332
434
  Effect.succeed({
333
435
  result: fileResult("skipped", []),
334
- requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
436
+ requests: judgeRequests(
437
+ plan.file.lines,
438
+ rulesOf(plan.pending),
439
+ Object.keys(plan.sampled),
440
+ ),
335
441
  }),
336
442
  ),
337
443
  // The firewall refuses this file's code every time; the rest of the run goes on.
@@ -342,10 +448,25 @@ export const executeAudit = (
342
448
  findings: [],
343
449
  blocked: { file: plan.file.path, ray },
344
450
  } satisfies FileResult,
345
- requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
451
+ requests: judgeRequests(
452
+ plan.file.lines,
453
+ rulesOf(plan.pending),
454
+ Object.keys(plan.sampled),
455
+ ),
346
456
  }),
347
457
  ),
348
458
  );
459
+ // Everything logged while judging a file, requests included, names the file.
460
+ // A file that sent nothing, whether cached or too long, is a trace line: a
461
+ // large repo's cache would otherwise bury the requests.
462
+ const [, { result, requests }] = yield* Effect.timed(judging).pipe(
463
+ Effect.tap(([elapsed, { result, requests }]) =>
464
+ (requests > 0 ? Effect.logDebug : Effect.logTrace)(
465
+ `${result.status}: ${requests} ${requests === 1 ? "request" : "requests"}, ${result.findings.length} ${result.findings.length === 1 ? "finding" : "findings"}, ${Math.round(Duration.toMillis(elapsed))} ms`,
466
+ ),
467
+ ),
468
+ Effect.annotateLogs("file", plan.file.path),
469
+ );
349
470
  yield* progress({ requests, findings: result.findings.length });
350
471
  return result;
351
472
  });
@@ -364,6 +485,28 @@ export const executeAudit = (
364
485
  };
365
486
  });
366
487
 
488
+ /**
489
+ * Deletes from the cache what the files and rules of a run no longer need:
490
+ * answers about content no file has, answers to rule texts no rule asks, and
491
+ * tallies of rules gone. Only after a run that read every file; a run narrowed
492
+ * by `--filter` cannot tell what the files it left out need.
493
+ */
494
+ export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
495
+ const config = yield* AdhereConfig;
496
+ const cache = yield* AuditCache;
497
+ const read = plan.files.filter((file) => !file.skipped);
498
+ const prepared = read.flatMap((file) => Object.values(file.prepared));
499
+ const tallies = yield* Effect.forEach(new Set(prepared.map(({ rule }) => rule)), (rule) =>
500
+ tallyKeyOf(config.model, rule),
501
+ );
502
+ const deleted = yield* cache.prune({
503
+ hashes: new Set(read.map((file) => file.hash)),
504
+ fingerprints: new Set(prepared.map(({ fingerprint }) => fingerprint)),
505
+ tallies: new Set(tallies),
506
+ });
507
+ yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);
508
+ });
509
+
367
510
  /** Plans the run and executes it, without asking and without a counter. */
368
511
  export const runAudit: Effect.Effect<
369
512
  AuditResult,
@@ -28,18 +28,19 @@ type Line = ReadonlyArray<Span>;
28
28
  * palette (`38;5;N`) or truecolor (`38;2;R;G;B`), bold with a trailing `;1`,
29
29
  * and a dim line number. Of the header, only the `×` and the rule are red,
30
30
  * since a whole line of red is hard to read: the probability is an accent, and
31
- * the description a soft white. The code's are the terminal's own colors
32
- * (`3N`), so the user's theme picks shades that read on its background; none
33
- * is magenta, which would run into the label.
31
+ * the description a cool near-white, set here since a theme's own white can
32
+ * be gray. The code's are the terminal's own colors (`3N`), so the user's
33
+ * theme picks shades that read on its background; none is magenta, which
34
+ * would run into the underline.
34
35
  */
35
36
  const THEME: Readonly<Record<Role, string>> = {
36
- error: "38;5;197;1",
37
- probability: "38;2;250;179;135",
38
- description: "38;2;242;205;205",
37
+ error: "38;2;164;20;71;1",
38
+ probability: "38;5;156",
39
+ description: "38;2;230;230;255",
39
40
  path: "38;2;5;125;160;1",
40
41
  lineNumber: "2",
41
42
  underline: "38;2;255;0;175",
42
- label: "38;5;212",
43
+ label: "38;2;242;205;205",
43
44
  comment: "2",
44
45
  string: "32",
45
46
  constant: "33",
@@ -97,17 +98,33 @@ const header = (finding: Finding): Line => [
97
98
  span(finding.description, "description"),
98
99
  ];
99
100
 
100
- /** The path, the offending line numbered in the gutter, and an underline as long as the line. */
101
+ /**
102
+ * The path, then the finding's excerpt with each line numbered in the gutter,
103
+ * and an underline under the offending line's code.
104
+ */
101
105
  const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => {
102
- const number = String(finding.line);
103
- const gutter = span(" ".repeat(number.length + 2));
104
- // A snippet is one line: `snippetOf` trims it out of the file.
105
- const [code = []] = highlighted(finding.snippet);
106
- const underline = "─".repeat(Math.max(1, finding.snippet.trim().length));
106
+ const { start, lines } = finding.excerpt;
107
+ const width = String(start + lines.length - 1).length;
108
+ const gutter = span(" ".repeat(width + 2));
109
+ const offending = lines[finding.line - start] ?? "";
110
+ // The line's own indentation, tabs and all, lines the underline up under its code.
111
+ const indent = offending.slice(0, offending.length - offending.trimStart().length);
112
+ const underline = "─".repeat(Math.max(1, offending.trim().length));
113
+ const location = `:${finding.line}:${indent.length + 1}]`;
107
114
  return [
108
- [gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(`:${number}:1]`)],
109
- [span(" "), span(number, "lineNumber"), span(" │ "), ...code],
110
- [gutter, span("· "), span(underline, "underline")],
115
+ [gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(location)],
116
+ ...highlighted(lines.join("\n")).flatMap((code, index): ReadonlyArray<Line> => {
117
+ const number = String(start + index);
118
+ const numbered = [
119
+ span(" ".repeat(width - number.length + 1)),
120
+ span(number, "lineNumber"),
121
+ span(" │ "),
122
+ ...code,
123
+ ];
124
+ return start + index === finding.line
125
+ ? [numbered, [gutter, span("· "), span(indent), span(underline, "underline")]]
126
+ : [numbered];
127
+ }),
111
128
  [gutter, span("╰────")],
112
129
  ];
113
130
  };
@@ -125,8 +142,8 @@ const hint = (finding: Finding): ReadonlyArray<Line> => {
125
142
 
126
143
  /**
127
144
  * One diagnostic, in the frame `vp lint` prints on a terminal: a header with
128
- * the rule in red, the offending line, a pink underline, and the code to write
129
- * as the hint.
145
+ * the rule in red, the code around the offending line, a pink underline under
146
+ * it, and the code to write as the hint.
130
147
  */
131
148
  const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
132
149
  header(finding),
@@ -1,19 +0,0 @@
1
- ---
2
- description: A named function that returns an Effect must be defined with Effect.fn so the call site is traced. An Effect held in a constant, such as `const program = Effect.gen(...)`, is not a function and is not in scope.
3
- ---
4
-
5
- ```ts must
6
- const processUser = Effect.fn("processUser")(function* (userId: string) {
7
- yield* Effect.logInfo(`Processing user ${userId}`);
8
- const user = yield* getUser(userId);
9
- return yield* processData(user);
10
- });
11
- ```
12
-
13
- ```ts never
14
- const loadUser = (id: string) =>
15
- Effect.gen(function* () {
16
- const repo = yield* UserRepo;
17
- return yield* repo.find(id);
18
- });
19
- ```
@@ -1,24 +0,0 @@
1
- ---
2
- description: A command handler must only parse input and call a service. Business logic must live in the service, never in the handler.
3
- ---
4
-
5
- ```ts must
6
- const addCommand = Command.make("add", { text }, ({ text }) =>
7
- Effect.gen(function* () {
8
- const repo = yield* TaskRepo;
9
- const task = yield* repo.add(text);
10
- yield* Console.log(`Added task #${task.id}: ${task.text}`);
11
- }),
12
- ).pipe(Command.withDescription("Add a new task"));
13
- ```
14
-
15
- ```ts never
16
- Command.make("rename", { from, to }, ({ from, to }) =>
17
- Effect.gen(function* () {
18
- const fs = yield* FileSystem.FileSystem;
19
- const text = yield* fs.readFileString(from);
20
- yield* fs.writeFileString(to, text.replaceAll("draft", "final"));
21
- yield* fs.remove(from);
22
- }),
23
- );
24
- ```
@@ -1,18 +0,0 @@
1
- ---
2
- description: JSON crossing a boundary must be decoded with Schema.fromJsonString, never with JSON.parse followed by a cast.
3
- ---
4
-
5
- ```ts must
6
- const MoveFromJson = Schema.fromJsonString(Move);
7
-
8
- const program = Effect.gen(function* () {
9
- const jsonString = '{"from":{"row":"A","column":"1"},"to":{"row":"B","column":"2"}}';
10
- const move = yield* Schema.decodeUnknownEffect(MoveFromJson)(jsonString);
11
- const json = yield* Schema.encodeEffect(MoveFromJson)(move);
12
- return json;
13
- });
14
- ```
15
-
16
- ```ts never
17
- const settings = JSON.parse(text) as Settings;
18
- ```
@@ -1,23 +0,0 @@
1
- ---
2
- description: Domain records must be defined with Schema.Class, never with a plain interface or type alias.
3
- ---
4
-
5
- ```ts must
6
- export class User extends Schema.Class<User>("User")({
7
- id: UserId,
8
- name: Schema.String,
9
- email: Schema.String,
10
- createdAt: Schema.Date,
11
- }) {
12
- get displayName() {
13
- return `${this.name} (${this.email})`;
14
- }
15
- }
16
- ```
17
-
18
- ```ts never
19
- interface Invoice {
20
- readonly id: InvoiceId;
21
- readonly total: Money;
22
- }
23
- ```
@@ -1,19 +0,0 @@
1
- ---
2
- description: A domain failure must be a Schema.TaggedError with its own tag, never a bare Error or a plain object.
3
- ---
4
-
5
- ```ts must
6
- class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
7
- field: Schema.String,
8
- message: Schema.String,
9
- }) {}
10
-
11
- class NotFoundError extends Schema.TaggedError<NotFoundError>()("NotFoundError", {
12
- resource: Schema.String,
13
- id: Schema.String,
14
- }) {}
15
- ```
16
-
17
- ```ts never
18
- return yield* Effect.fail(new Error("Card declined"));
19
- ```
@@ -1,26 +0,0 @@
1
- ---
2
- description: File, path, process, terminal, and HTTP access must go through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, never through node: builtins, Bun globals, or fetch. The Bun implementations must be provided once at the entry point with BunServices.layer.
3
- ---
4
-
5
- ```ts must
6
- import { Effect, FileSystem, Path } from "effect";
7
- import { HttpClient, HttpClientResponse } from "effect/unstable/http";
8
- import { BunRuntime, BunServices } from "@effect/platform-bun";
9
-
10
- const program = Effect.gen(function* () {
11
- const fs = yield* FileSystem.FileSystem;
12
- const path = yield* Path.Path;
13
- const config = yield* fs.readFileString(path.join(path.resolve(), "config.json"));
14
- const response = yield* HttpClient.get("https://api.example.com/users");
15
- const users = yield* HttpClientResponse.schemaBodyJson(Users)(response);
16
- return { config, users };
17
- });
18
-
19
- program.pipe(Effect.provide(BunServices.layer), BunRuntime.runMain);
20
- ```
21
-
22
- ```ts never
23
- import { readFileSync } from "node:fs";
24
-
25
- const settings = readFileSync("settings.json", "utf8");
26
- ```
@@ -1,20 +0,0 @@
1
- ---
2
- description: Service method signatures must have no requirements. Dependencies must be acquired inside the layer, never declared on the method.
3
- ---
4
-
5
- ```ts must
6
- class Database extends Context.Service<
7
- Database,
8
- {
9
- readonly query: (sql: string) => Effect.Effect<unknown[]>;
10
- readonly execute: (sql: string) => Effect.Effect<void>;
11
- }
12
- >()("@app/Database") {}
13
- ```
14
-
15
- ```ts never
16
- class Orders extends Context.Service<
17
- Orders,
18
- { readonly save: (order: Order) => Effect.Effect<void, SaveError, Database> }
19
- >()("@app/Orders") {}
20
- ```
@@ -1,23 +0,0 @@
1
- ---
2
- description: Layers must be provided once at the program entry. A module that is not an entry point must never call Effect.provide.
3
- ---
4
-
5
- ```ts must
6
- const appLayer = userServiceLayer.pipe(
7
- Layer.provideMerge(databaseLayer),
8
- Layer.provideMerge(loggerLayer),
9
- Layer.provideMerge(configLayer),
10
- );
11
-
12
- const program = Effect.gen(function* () {
13
- const users = yield* UserService;
14
- yield* users.getUser();
15
- });
16
-
17
- const main = program.pipe(Effect.provide(appLayer));
18
- ```
19
-
20
- ```ts never
21
- export const getUser = (id: string) =>
22
- UserRepo.find(id).pipe(Effect.provide(UserRepo.layer));
23
- ```
@@ -1,22 +0,0 @@
1
- ---
2
- description: A test that depends on randomness must control it with a fixed seed through Random.withSeed, never with Math.random or an unseeded Random.
3
- ---
4
-
5
- ```ts must
6
- const program = Effect.gen(function* () {
7
- const value1 = yield* Random.next;
8
- const value2 = yield* Random.next;
9
- return [value1, value2];
10
- });
11
-
12
- await Effect.runPromise(
13
- Effect.all([program.pipe(Random.withSeed("my-seed")), program.pipe(Random.withSeed("my-seed"))]),
14
- ); // => [[0.018368576514773527, 0.4010840628128671], [0.018368576514773527, 0.4010840628128671]]
15
- ```
16
-
17
- ```ts never
18
- it("creates unique ids", () => {
19
- const id = Math.random().toString(36).slice(2);
20
- expect(id).toHaveLength(11);
21
- });
22
- ```