@drkmttr/adhere 0.6.0 → 0.6.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.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # adhere
2
2
 
3
3
  A linter for rules a normal linter cannot check. A rule is a one-sentence
4
- description plus correct reference code, code to avoid, or both. For each
4
+ description like "business logic should live in services", plus correct
5
+ reference code / code to avoid / or both. For each
5
6
  source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
6
7
  model) whether the file breaks each rule, gets a calibrated probability per
7
8
  rule, and reports the ones above a threshold with the line Jev points at. The
@@ -9,18 +10,23 @@ report uses the same frame as `vp lint`.
9
10
 
10
11
  Deterministic rules (substring matches, type checks) belong in a normal linter.
11
12
 
13
+ A preset for effect is included which can be run without setup:
14
+
15
+ ```sh
16
+ TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset effect
17
+ ```
18
+
12
19
  ## Install
13
20
 
14
21
  ```sh
15
- npm install --global @drkmttr/adhere # or: bun add --global @drkmttr/adhere
22
+ bun add --global @drkmttr/adhere # or: npm install --global @drkmttr/adhere
16
23
  ```
17
24
 
18
25
  To pin the version in a repo, for CI or scripts, add it as a dev dependency
19
- and run `npx adhere`. To try it without installing, run
20
- `npx @drkmttr/adhere` or `bunx @drkmttr/adhere`.
26
+ and run `npx adhere`.
21
27
 
22
28
  `adhere` is a prebuilt executable for macOS and Linux on arm64 and x64 and for
23
- Windows on x64. It needs Node to start, and no Bun.
29
+ Windows on x64. .
24
30
 
25
31
  adhere sends each file it judges to Jev at `api.typesafe.ai`, authenticated
26
32
  with a TypeSafe AI API key: the one `adhere login` saved, or
@@ -32,7 +38,7 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
32
38
  adhere init # .adhere/config.ts and two example rules
33
39
  echo ".adhere/cache/" >> .gitignore
34
40
  adhere login # save your TypeSafe AI API key, once
35
- adhere validate # check the rules, and ask Jev whether any contradict
41
+ adhere validate # Checks your rules for contradictions using Jev
36
42
  adhere lint # audit the working directory
37
43
  ```
38
44
 
@@ -275,13 +281,13 @@ beats all of the above for that rule.
275
281
 
276
282
  1. One Jev request per file. The state is the file's numbered lines and
277
283
  nothing else. Each rule is one `noul` (yes/no probability) question that
278
- carries the rule: whether the file breaks the rule's description, with
279
- criteria for each answer. Yes means part of the file breaks the rule, and
280
- the code to avoid is an example of it. No means the file follows the rule
281
- or has nothing the rule covers, and the reference is an example of it.
282
- Since no rule sits in the shared state, a rule's probability depends only
283
- on the file and that rule, not on which other rules share the request.
284
- Every question shares the state cost of the request.
284
+ carries the rule's description, reference, and code to avoid: whether the
285
+ file diverges from the reference or, for a rule with only code to avoid,
286
+ whether it contains that code. Criteria for each answer draw the line at
287
+ the rule's scope, so a file with no code the rule is about is a no. Since
288
+ no rule sits in the shared state, a rule's probability depends only on the
289
+ file and that rule, not on which other rules share the request. Every
290
+ question shares the state cost of the request.
285
291
  2. A second request only when at least one rule's probability is above its
286
292
  threshold: one `choice` question per flagged rule over the file's non-blank
287
293
  lines, which yields the line to report. A file with more than 255 non-blank
@@ -291,7 +297,9 @@ beats all of the above for that rule.
291
297
  about three bytes a token. When a file's questions would not fit in one
292
298
  request, they are split across several. A file whose code and longest
293
299
  question would not fit together, or that has more than 5100 lines, is
294
- skipped and counted in the summary, and the rest of the run goes on.
300
+ skipped and counted in the summary, and the rest of the run goes on. So is
301
+ a file Jev itself counts as over its context, which text denser than the
302
+ estimate, such as CJK, can cause.
295
303
 
296
304
  ## Cache
297
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "description": "Lints a repository against reference code in .adhere/, judged by Jev.",
6
6
  "repository": {
@@ -67,10 +67,10 @@
67
67
  }
68
68
  },
69
69
  "optionalDependencies": {
70
- "@drkmttr/adhere-darwin-arm64": "0.6.0",
71
- "@drkmttr/adhere-darwin-x64": "0.6.0",
72
- "@drkmttr/adhere-linux-arm64": "0.6.0",
73
- "@drkmttr/adhere-linux-x64": "0.6.0",
74
- "@drkmttr/adhere-win32-x64": "0.6.0"
70
+ "@drkmttr/adhere-darwin-arm64": "0.6.1",
71
+ "@drkmttr/adhere-darwin-x64": "0.6.1",
72
+ "@drkmttr/adhere-linux-arm64": "0.6.1",
73
+ "@drkmttr/adhere-linux-x64": "0.6.1",
74
+ "@drkmttr/adhere-win32-x64": "0.6.1"
75
75
  }
76
76
  }
@@ -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
  );
@@ -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,9 +91,12 @@ 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 rule as its questions carry it: the description, and its code on either side. */
95
+ const ruleFields = (rule: Rule) => ({
96
+ rule: rule.description,
97
+ ...(rule.reference === undefined ? {} : { reference: rule.reference }),
98
+ ...(rule.avoid === undefined ? {} : { avoid: rule.avoid }),
99
+ });
90
100
 
91
101
  /** A rule's text as a comparison of rules reads it. Code a rule lacks is left out, not sent empty. */
92
102
  const ruleState = ({ description, reference, avoid }: Rule) => ({
@@ -112,28 +122,43 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
112
122
 
113
123
  /**
114
124
  * 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.
125
+ * state, so each question reads its own rule and no other. A rule with a
126
+ * reference asks whether the file diverges from it, with the code to avoid
127
+ * as an example when there is some. A rule without one asks whether the file
128
+ * contains the code to avoid. The criteria draw the line at the rule's scope:
129
+ * a file with no code the rule is about is a no.
118
130
  */
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
- });
131
+ export const judgeQuestion = (rule: Rule) => {
132
+ const example = rule.avoid === undefined ? "" : ", for example by doing what `avoid` shows";
133
+ const [question, yes, no] =
134
+ rule.reference === undefined
135
+ ? [
136
+ "Does `code` contain the pattern shown in `avoid`, which `rule` rules out? Answer no if nothing in this file resembles it.",
137
+ "`code` contains the pattern shown in `avoid`",
138
+ "`code` has nothing like `avoid`",
139
+ ]
140
+ : [
141
+ `Does \`code\` diverge from the pattern shown in \`reference\`${example}, as described by \`rule\`? Answer no if the pattern does not apply to this file.`,
142
+ `\`code\` diverges from the pattern shown in \`reference\`${example}`,
143
+ "`code` follows the pattern",
144
+ ];
145
+ return {
146
+ type: "noul" as const,
147
+ instructions: { question, ...ruleFields(rule) },
148
+ criteria: {
149
+ true: `${yes}, in code that \`rule\` is about`,
150
+ false: `${no}, or has no code that \`rule\` is about`,
151
+ },
152
+ };
153
+ };
130
154
 
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
- });
155
+ /** What the offending line does, for the line and block questions. */
156
+ const offense = (rule: Rule): string =>
157
+ rule.reference === undefined
158
+ ? "shows the pattern in `avoid`"
159
+ : rule.avoid === undefined
160
+ ? "diverges from `reference`"
161
+ : "diverges from `reference` or resembles `avoid`";
137
162
 
138
163
  const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
139
164
  const criteria: Record<string, string> = {};
@@ -163,7 +188,7 @@ export const locateBody = (
163
188
  return {
164
189
  type: "choice" as const,
165
190
  instructions: {
166
- question: "Which line of `code` most clearly breaks `rule`?",
191
+ question: `Which line of \`code\` most clearly ${offense(rule)}?`,
167
192
  ...ruleFields(rule),
168
193
  },
169
194
  criteria:
@@ -188,7 +213,7 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
188
213
  questions: Record.map(rules, (rule) => ({
189
214
  type: "choice" as const,
190
215
  instructions: {
191
- question: "Which block of `code` contains the line that most clearly breaks `rule`?",
216
+ question: `Which block of \`code\` contains the line that most clearly ${offense(rule)}?`,
192
217
  ...ruleFields(rule),
193
218
  },
194
219
  criteria,
@@ -98,12 +98,16 @@ const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(som
98
98
  const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
99
99
 
100
100
  /** 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
- );
101
+ const inFile =
102
+ (file: ScannedFile) =>
103
+ <A, E, R>(self: Effect.Effect<A, E, R>) =>
104
+ Effect.mapError(self, (problem) =>
105
+ problem instanceof JevUnavailable
106
+ ? JevUnavailable.make({
107
+ message: `${file.path}: ${problem.message}. Files judged before it are cached, so a rerun continues from there.`,
108
+ })
109
+ : problem,
110
+ );
107
111
 
108
112
  export const planAudit: Effect.Effect<
109
113
  AuditPlan,
@@ -181,18 +185,14 @@ export const executeAudit = (
181
185
  const jev = yield* Jev;
182
186
  const cache = yield* AuditCache;
183
187
 
184
- const auditFile = Effect.fn("audit.file")(function* ({
188
+ /** Judges and locates one file the plan left pending, with the requests that took. */
189
+ const judgeFile = Effect.fn("audit.judgeFile")(function* ({
185
190
  file,
186
- skipped,
187
191
  hash,
188
192
  prepared,
189
193
  kept,
190
194
  pending,
191
195
  }: FilePlan) {
192
- if (skipped) {
193
- yield* progress({ requests: 0, findings: 0 });
194
- return fileResult("skipped", []);
195
- }
196
196
  const probabilities = isEmpty(pending)
197
197
  ? {}
198
198
  : yield* jev.judge(file.lines, rulesOf(pending)).pipe(inFile(file));
@@ -244,13 +244,28 @@ export const executeAudit = (
244
244
  : [];
245
245
  })
246
246
  .sort((a, b) => a.line - b.line);
247
- yield* progress({
247
+ return {
248
+ result: fileResult(cached ? "cached" : "judged", findings),
248
249
  requests:
249
250
  (isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
250
251
  (isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
251
- findings: findings.length,
252
- });
253
- return fileResult(cached ? "cached" : "judged", findings);
252
+ };
253
+ });
254
+
255
+ const auditFile = Effect.fn("audit.file")(function* (plan: FilePlan) {
256
+ const { result, requests } = plan.skipped
257
+ ? { result: fileResult("skipped", []), requests: 0 }
258
+ : yield* judgeFile(plan).pipe(
259
+ // Jev counted more than `fits` estimated. Its count decides: skipped the same way.
260
+ Effect.catchTag("JevOverflow", () =>
261
+ Effect.succeed({
262
+ result: fileResult("skipped", []),
263
+ requests: judgeRequests(plan.file.lines, rulesOf(plan.pending)),
264
+ }),
265
+ ),
266
+ );
267
+ yield* progress({ requests, findings: result.findings.length });
268
+ return result;
254
269
  });
255
270
 
256
271
  const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });