@drkmttr/adhere 0.6.0 → 0.7.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 (37) hide show
  1. package/README.md +75 -39
  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 +2 -2
  31. package/src/config.ts +67 -13
  32. package/src/init.ts +10 -8
  33. package/src/markdown.ts +59 -25
  34. package/src/services/Jev.http.ts +36 -11
  35. package/src/services/Jev.ts +75 -36
  36. package/src/workflows/audit.ts +34 -21
  37. package/src/workflows/format.ts +7 -3
@@ -7,6 +7,7 @@ import {
7
7
  conflictBody,
8
8
  contradictBody,
9
9
  Jev,
10
+ JevOverflow,
10
11
  JevUnavailable,
11
12
  judgeBody,
12
13
  type Lines,
@@ -18,7 +19,7 @@ import {
18
19
  requestsOf,
19
20
  type Rules,
20
21
  } from "#services/Jev.ts";
21
- import { type Cause, Effect, Layer, Record, Schedule, Schema } from "effect";
22
+ import { type Cause, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
22
23
  import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
23
24
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
24
25
 
@@ -39,21 +40,41 @@ const detailOf = (body: string): string => {
39
40
  return text.length === 0 ? "" : `: ${text.slice(0, 300)}`;
40
41
  };
41
42
 
43
+ /** What Jev answers, with a 400, to a request over its context. */
44
+ const Overflowed = Schema.fromJsonString(
45
+ Schema.Struct({ detail: Schema.Struct({ error_type: Schema.Literal("max_tokens_exceeded") }) }),
46
+ );
47
+ const isOverflow = (body: string): boolean =>
48
+ Option.isSome(Schema.decodeUnknownOption(Overflowed)(body));
49
+
42
50
  /**
43
- * A failed request as a refusal: for an answer outside 2xx, its status and
44
- * what Jev said, since the status alone does not say why; otherwise the cause.
51
+ * A request that failed. A 400 saying the request is over Jev's context is a
52
+ * `JevOverflow`, which skips the file. Any other answer outside 2xx refuses
53
+ * the run with its status and what Jev said, since the status alone does not
54
+ * say why, and a request that got no answer refuses with the cause.
45
55
  */
46
- const refusalOf = (problem: HttpClientError.HttpClientError | Cause.TimeoutError) => {
56
+ const failed = (problem: HttpClientError.HttpClientError | Cause.TimeoutError) => {
47
57
  if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
48
- return Effect.succeed(refused(`System One request failed: ${problem.message}`));
58
+ return Effect.fail(refused(`System One request failed: ${problem.message}`));
49
59
  }
50
60
  const { response } = problem.reason;
51
- return Effect.map(
61
+ return Effect.flatMap(
52
62
  Effect.orElseSucceed(response.text, () => ""),
53
- (body) => refused(`Jev answered HTTP ${response.status}${detailOf(body)}`),
63
+ (body): Effect.Effect<never, JevOverflow | JevUnavailable> =>
64
+ response.status === 400 && isOverflow(body)
65
+ ? Effect.fail(JevOverflow.make())
66
+ : Effect.fail(refused(`Jev answered HTTP ${response.status}${detailOf(body)}`)),
54
67
  );
55
68
  };
56
69
 
70
+ /** Comparing rules has no file to skip, so a request over Jev's context refuses instead. */
71
+ const refuseOverflow = <A>(
72
+ self: Effect.Effect<A, JevOverflow | JevUnavailable>,
73
+ ): Effect.Effect<A, JevUnavailable> =>
74
+ Effect.catchTag(self, "JevOverflow", () =>
75
+ Effect.fail(refused("Comparing the rules took a request over Jev's context")),
76
+ );
77
+
57
78
  export const JevLive = Layer.effect(Jev)(
58
79
  Effect.gen(function* () {
59
80
  const config = yield* AdhereConfig;
@@ -79,7 +100,7 @@ export const JevLive = Layer.effect(Jev)(
79
100
  ).pipe(Effect.orDie);
80
101
  const response = yield* client
81
102
  .execute(HttpClientRequest.bearerToken(request, apiKey))
82
- .pipe(Effect.catch((problem) => Effect.flatMap(refusalOf(problem), Effect.fail)));
103
+ .pipe(Effect.catch(failed));
83
104
  return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
84
105
  Effect.mapError((problem) =>
85
106
  refused(`System One response did not decode: ${problem.message}`),
@@ -127,7 +148,10 @@ export const JevLive = Layer.effect(Jev)(
127
148
  rules: ReadonlyArray<ComparedRule>,
128
149
  partners: ReadonlyArray<ReadonlyArray<number>>,
129
150
  ) {
130
- const answers = yield* answersTo(conflictBody(config.model, rules, partners), ChoiceAnswers);
151
+ const answers = yield* answersTo(
152
+ conflictBody(config.model, rules, partners),
153
+ ChoiceAnswers,
154
+ ).pipe(refuseOverflow);
131
155
  return namedPairs(answers, partners);
132
156
  });
133
157
 
@@ -138,8 +162,9 @@ export const JevLive = Layer.effect(Jev)(
138
162
  return yield* Effect.forEach(
139
163
  pairs,
140
164
  (pair) =>
141
- Effect.map(ask(contradictBody(config.model, rules, pair), NoulAnswers), ({ answers }) =>
142
- pairProbability(answers, pair),
165
+ Effect.map(
166
+ ask(contradictBody(config.model, rules, pair), NoulAnswers).pipe(refuseOverflow),
167
+ ({ answers }) => pairProbability(answers, pair),
143
168
  ),
144
169
  { concurrency: 8 },
145
170
  );
@@ -1,4 +1,4 @@
1
- import type { Rule, RuleId, Rules } from "#config.ts";
1
+ import { type Example, examplesOf, type Rule, type RuleId, type Rules } from "#config.ts";
2
2
  import { Context, type Effect, Record, Schema } from "effect";
3
3
 
4
4
  export type { Rules };
@@ -8,17 +8,23 @@ export class JevUnavailable extends Schema.TaggedError<JevUnavailable>()("JevUna
8
8
  message: Schema.String,
9
9
  }) {}
10
10
 
11
+ /**
12
+ * Jev's refusal of a request over its context. `fits` keeps such files from
13
+ * being sent, but by an estimate; this is Jev's own count disagreeing.
14
+ */
15
+ export class JevOverflow extends Schema.TaggedError<JevOverflow>()("JevOverflow", {}) {}
16
+
11
17
  export class Jev extends Context.Service<
12
18
  Jev,
13
19
  {
14
20
  readonly judge: (
15
21
  lines: Lines,
16
22
  rules: Rules,
17
- ) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
23
+ ) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
18
24
  readonly locate: (
19
25
  lines: Lines,
20
26
  rules: Rules,
21
- ) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
27
+ ) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow>;
22
28
  /**
23
29
  * Per rule, the partner Jev names as impossible to follow in the same code,
24
30
  * as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
@@ -66,8 +72,9 @@ const encoder = new TextEncoder();
66
72
 
67
73
  /**
68
74
  * An estimate, meant to run high, of the tokens Jev reads for a value: one
69
- * per three bytes of its JSON. Code runs a little over three bytes a token,
70
- * prose about four.
75
+ * per three bytes of its JSON. Jev 1.13 reads numbered TypeScript at 3.2
76
+ * bytes a token, and this ran 12 to 16 percent high on the eval's requests.
77
+ * Text that packs more tokens into its bytes, such as CJK, can outrun it.
71
78
  */
72
79
  export const tokensOf = (value: unknown): number =>
73
80
  Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
@@ -84,16 +91,23 @@ const stateOf = (lines: Lines) => ({
84
91
  code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
85
92
  });
86
93
 
87
- /** One side of a noul: what it means, with the rule's code for that side as an example. */
88
- const sideOf = (what: string, example: string | undefined) =>
89
- example === undefined ? { what } : { what, examples: [example] };
94
+ /** The field an example goes under in a question: its word, with `should not` as `should_not`. */
95
+ const fieldOf = (example: Example): string => example.word.replace(" ", "_");
90
96
 
91
- /** A rule's text as a comparison of rules reads it. Code a rule lacks is left out, not sent empty. */
92
- const ruleState = ({ description, reference, avoid }: Rule) => ({
93
- description,
94
- ...(reference === undefined ? {} : { reference }),
95
- ...(avoid === undefined ? {} : { avoid }),
96
- });
97
+ /** A rule's examples under their words. Code a rule lacks is left out, not sent empty. */
98
+ const exampleFields = (rule: Rule): Record<string, string> => {
99
+ const { good, bad } = examplesOf(rule);
100
+ return {
101
+ ...(good === undefined ? {} : { [fieldOf(good)]: good.code }),
102
+ ...(bad === undefined ? {} : { [fieldOf(bad)]: bad.code }),
103
+ };
104
+ };
105
+
106
+ /** The rule as its questions carry it: the description, and its examples under their words. */
107
+ const ruleFields = (rule: Rule) => ({ rule: rule.description, ...exampleFields(rule) });
108
+
109
+ /** A rule's text as a comparison of rules reads it. */
110
+ const ruleState = (rule: Rule) => ({ description: rule.description, ...exampleFields(rule) });
97
111
 
98
112
  /**
99
113
  * The compared rules a question mentions, keyed by index: ids alone would
@@ -112,28 +126,53 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
112
126
 
113
127
  /**
114
128
  * Whether the file breaks the rule. The rule rides in the question, not the
115
- * state, so each question reads its own rule and no other. The criteria draw
116
- * the line: code to avoid is a yes, and the reference is a no, as is a file
117
- * with nothing the rule covers.
129
+ * state, so each question reads its own rule and no other, its code under its
130
+ * own words: `must` and `never`, or a guideline's `should` and `should_not`.
131
+ * A rule with code to write asks whether the file diverges from it, with the
132
+ * code not to write as an example when there is some. A rule without any
133
+ * asks whether the file contains the code not to write. The criteria draw the
134
+ * line at the rule's scope: a file with no code the rule is about is a no.
135
+ *
136
+ * On the eval, "diverge from the pattern shown in `must`" judged better than
137
+ * asking whether code "must be written the way `must` shows": the same words,
138
+ * but a pattern to compare rather than code to match.
118
139
  */
119
- export const judgeQuestion = (rule: Rule) => ({
120
- type: "noul" as const,
121
- instructions: { question: "Does `code` break `rule`?", rule: rule.description },
122
- criteria: {
123
- true: sideOf("Part of `code` breaks `rule`", rule.avoid),
124
- false: sideOf(
125
- "Every part of `code` that `rule` covers follows it, or `rule` covers no part of `code`",
126
- rule.reference,
127
- ),
128
- },
129
- });
140
+ export const judgeQuestion = (rule: Rule) => {
141
+ const { good, bad } = examplesOf(rule);
142
+ const avoided = fieldOf(bad ?? { word: "never", code: "" });
143
+ const example = bad === undefined ? "" : `, for example by doing what \`${avoided}\` shows`;
144
+ const [question, yes, no] =
145
+ good === undefined
146
+ ? [
147
+ `Does \`code\` contain the pattern shown in \`${avoided}\`, which \`rule\` rules out? Answer no if nothing in this file resembles it.`,
148
+ `\`code\` contains the pattern shown in \`${avoided}\``,
149
+ `\`code\` has nothing like \`${avoided}\``,
150
+ ]
151
+ : [
152
+ `Does \`code\` diverge from the pattern shown in \`${fieldOf(good)}\`${example}, as described by \`rule\`? Answer no if the pattern does not apply to this file.`,
153
+ `\`code\` diverges from the pattern shown in \`${fieldOf(good)}\`${example}`,
154
+ "`code` follows the pattern",
155
+ ];
156
+ return {
157
+ type: "noul" as const,
158
+ instructions: { question, ...ruleFields(rule) },
159
+ criteria: {
160
+ true: `${yes}, in code that \`rule\` is about`,
161
+ false: `${no}, or has no code that \`rule\` is about`,
162
+ },
163
+ };
164
+ };
130
165
 
131
- /** The rule as a line question carries it: the description, and its code on either side. */
132
- const ruleFields = (rule: Rule) => ({
133
- rule: rule.description,
134
- ...(rule.reference === undefined ? {} : { follows_rule: rule.reference }),
135
- ...(rule.avoid === undefined ? {} : { breaks_rule: rule.avoid }),
136
- });
166
+ /** What the offending line does, for the line and block questions. */
167
+ const offense = (rule: Rule): string => {
168
+ const { good, bad } = examplesOf(rule);
169
+ const avoided = `\`${fieldOf(bad ?? { word: "never", code: "" })}\``;
170
+ return good === undefined
171
+ ? `shows the pattern in ${avoided}`
172
+ : bad === undefined
173
+ ? `diverges from \`${fieldOf(good)}\``
174
+ : `diverges from \`${fieldOf(good)}\` or resembles ${avoided}`;
175
+ };
137
176
 
138
177
  const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
139
178
  const criteria: Record<string, string> = {};
@@ -163,7 +202,7 @@ export const locateBody = (
163
202
  return {
164
203
  type: "choice" as const,
165
204
  instructions: {
166
- question: "Which line of `code` most clearly breaks `rule`?",
205
+ question: `Which line of \`code\` most clearly ${offense(rule)}?`,
167
206
  ...ruleFields(rule),
168
207
  },
169
208
  criteria:
@@ -188,7 +227,7 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
188
227
  questions: Record.map(rules, (rule) => ({
189
228
  type: "choice" as const,
190
229
  instructions: {
191
- question: "Which block of `code` contains the line that most clearly breaks `rule`?",
230
+ question: `Which block of \`code\` contains the line that most clearly ${offense(rule)}?`,
192
231
  ...ruleFields(rule),
193
232
  },
194
233
  criteria,
@@ -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;
@@ -98,12 +97,16 @@ const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(som
98
97
  const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
99
98
 
100
99
  /** A refusal that names the file it stopped at. Files finished before it are already cached. */
101
- const inFile = (file: ScannedFile) =>
102
- Effect.mapError((problem: JevUnavailable) =>
103
- JevUnavailable.make({
104
- message: `${file.path}: ${problem.message}. Files judged before it are cached, so a rerun continues from there.`,
105
- }),
106
- );
100
+ const inFile =
101
+ (file: ScannedFile) =>
102
+ <A, E, R>(self: Effect.Effect<A, E, R>) =>
103
+ Effect.mapError(self, (problem) =>
104
+ problem instanceof JevUnavailable
105
+ ? JevUnavailable.make({
106
+ message: `${file.path}: ${problem.message}. Files judged before it are cached, so a rerun continues from there.`,
107
+ })
108
+ : problem,
109
+ );
107
110
 
108
111
  export const planAudit: Effect.Effect<
109
112
  AuditPlan,
@@ -181,18 +184,14 @@ export const executeAudit = (
181
184
  const jev = yield* Jev;
182
185
  const cache = yield* AuditCache;
183
186
 
184
- const auditFile = Effect.fn("audit.file")(function* ({
187
+ /** Judges and locates one file the plan left pending, with the requests that took. */
188
+ const judgeFile = Effect.fn("audit.judgeFile")(function* ({
185
189
  file,
186
- skipped,
187
190
  hash,
188
191
  prepared,
189
192
  kept,
190
193
  pending,
191
194
  }: FilePlan) {
192
- if (skipped) {
193
- yield* progress({ requests: 0, findings: 0 });
194
- return fileResult("skipped", []);
195
- }
196
195
  const probabilities = isEmpty(pending)
197
196
  ? {}
198
197
  : yield* jev.judge(file.lines, rulesOf(pending)).pipe(inFile(file));
@@ -233,8 +232,7 @@ export const executeAudit = (
233
232
  {
234
233
  rule: id,
235
234
  description: k.rule.description,
236
- reference: k.rule.reference,
237
- avoid: k.rule.avoid,
235
+ examples: examplesOf(k.rule),
238
236
  file: file.path,
239
237
  line: judgment.line,
240
238
  snippet: judgment.snippet ?? "",
@@ -244,13 +242,28 @@ export const executeAudit = (
244
242
  : [];
245
243
  })
246
244
  .sort((a, b) => a.line - b.line);
247
- yield* progress({
245
+ return {
246
+ result: fileResult(cached ? "cached" : "judged", findings),
248
247
  requests:
249
248
  (isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
250
249
  (isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
251
- findings: findings.length,
252
- });
253
- return fileResult(cached ? "cached" : "judged", findings);
250
+ };
251
+ });
252
+
253
+ const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
254
+ const { result, requests } = plan.skipped
255
+ ? { result: fileResult("skipped", []), requests: 0 }
256
+ : yield* judgeFile(plan).pipe(
257
+ // Jev counted more than `fits` estimated. Its count decides: skipped the same way.
258
+ Effect.catchTag("JevOverflow", () =>
259
+ Effect.succeed({
260
+ result: fileResult("skipped", []),
261
+ requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
262
+ }),
263
+ ),
264
+ );
265
+ yield* progress({ requests, findings: result.findings.length });
266
+ return result;
254
267
  });
255
268
 
256
269
  const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
@@ -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),