@drkmttr/adhere 0.17.5 → 0.18.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.
package/README.md CHANGED
@@ -572,11 +572,42 @@ finding its matchers dropped, with their scores.
572
572
 
573
573
  Beside the file's code, Jev reads three kinds of context, for every rule:
574
574
 
575
- | Name | What Jev reads |
576
- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
577
- | `workspacePackages` | The repo's own packages the file imports, each name with its directory, as in `{ "orders-core": "packages/orders" }`. A package it imports that is not listed is an installed one. |
578
- | `symbols` | The compiler's type blocks for each name, plus compiler documentation and JSDoc tags; not literal editor hover text. |
579
- | `references` | The declaration of each name the file uses from another file of the repo, whole: a function with its body, a method without the rest of its class. |
575
+ | Name | What Jev reads |
576
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
577
+ | `workspacePackages` | The repo's own packages the file imports, each name with its directory, as in `{ "orders-core": "packages/orders" }`. A package it imports that is not listed is an installed one. |
578
+ | `symbols` | The compiler's type blocks for each name, plus compiler documentation and JSDoc tags; not literal editor hover text. |
579
+ | `references` | Whole declarations of symbols the code uses from other files of the repo, keyed by `symbol:repository-relative pathname`: a function with its body, a method without the rest of its class. |
580
+
581
+ `symbols` omits standard-library and Node/Bun runtime built-ins, including their
582
+ methods. Project and third-party symbols remain, even when they share a built-in
583
+ name. Built-in type names can still appear inside those symbols' type signatures.
584
+
585
+ `references` is a flat string-to-string map. Keys keep the symbol's name as used
586
+ in the target code, including import aliases, and the pathname's original casing.
587
+ Values are complete declaration excerpts, not incoming reference call sites.
588
+ Overlapping declarations under one key are merged; disjoint excerpts are joined
589
+ with `…` between them. For example, code importing `twice as scale` can receive:
590
+
591
+ ```json
592
+ {
593
+ "references": {
594
+ "__comment": "Declarations of symbols used by the target code, provided only as context. Keys are symbol:repository-relative pathname; values are source excerpts, not incoming call sites.",
595
+ "scale:src/Math.ts": "export function twice(value: number): number {\n return value * 2;\n}"
596
+ }
597
+ }
598
+ ```
599
+
600
+ When sent to Jev, each selected `references`, `symbols`, and `workspacePackages`
601
+ map includes a reserved `__comment` explaining its contents. These comments are
602
+ included before `appendState` hooks and restored after hooks. Top-level guidance
603
+ also tells Jev that these maps are context only: judge the target `code`, not the
604
+ additional declarations or metadata.
605
+
606
+ Each symbol's complete resolved content is truncated to 256 characters by
607
+ default. Set `maxSymbolLength` in the config to change this limit. Documentation
608
+ and JSDoc tags are first capped separately by `maxSymbolDocumentationLength`, also
609
+ 256 by default; `0` omits documentation and tags. Smaller limits reduce request
610
+ size but can cut off type signatures, overloads, useful details, or examples.
580
611
 
581
612
  To read fewer, list the ones you want in the config's `reads`:
582
613
 
@@ -750,6 +781,8 @@ export default defineConfig({
750
781
  sufficiencyThreshold: 0.6,
751
782
  reads: ["workspacePackages", "symbols", "references"],
752
783
  presets: ["effect"],
784
+ maxSymbolDocumentationLength: 256,
785
+ maxSymbolLength: 256,
753
786
  exclude: ["**/generated/**"],
754
787
  overrides: { "effect/basics/instrument-with-pipe": "off" },
755
788
  rules: {
@@ -767,18 +800,20 @@ type UserId = typeof UserId.Type
767
800
  });
768
801
  ```
769
802
 
770
- | Key | Default | Meaning |
771
- | ---------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
772
- | `model` | `"jev-latest"` | The model id sent to TypeSafe. |
773
- | `threshold` | `0.8` | Report a rule when Jev's probability is above this. |
774
- | `sufficiencyThreshold` | `0.6` | Below it, a finding warns that Jev may not see enough to decide. |
775
- | `presets` | none | Built-in rule sets, or their topics. See [Presets](#presets). |
776
- | `rules` | `.adhere/rules/` | Rules inline, as above, or a directory (see [Where rules live](#where-rules-live)). Either replaces `.adhere/rules/`: none of its rules, root or nested, is read then. |
777
- | `exclude` | none | Globs, relative to the working directory, of files no rule judges. |
778
- | `overrides` | none | Settings for a rule by its id. See [Overrides](#overrides). |
779
- | `includeComments` | `false` | Send every comment to Jev. See [Comments](#comments). |
780
- | `reads` | `["workspacePackages", "symbols", "references"]` | What Jev reads beside each file's code, for every rule. `[]` is the code alone. See [What Jev reads](#what-jev-reads). |
781
- | `provider` | TypeSafe's Jev | Where the questions go instead, with its own model. See [Providers](#providers). |
803
+ | Key | Default | Meaning |
804
+ | ------------------------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
805
+ | `model` | `"jev-latest"` | The model id sent to TypeSafe. |
806
+ | `threshold` | `0.8` | Report a rule when Jev's probability is above this. |
807
+ | `sufficiencyThreshold` | `0.6` | Below it, a finding warns that Jev may not see enough to decide. |
808
+ | `presets` | none | Built-in rule sets, or their topics. See [Presets](#presets). |
809
+ | `rules` | `.adhere/rules/` | Rules inline, as above, or a directory (see [Where rules live](#where-rules-live)). Either replaces `.adhere/rules/`: none of its rules, root or nested, is read then. |
810
+ | `exclude` | none | Globs, relative to the working directory, of files no rule judges. |
811
+ | `overrides` | none | Settings for a rule by its id. See [Overrides](#overrides). |
812
+ | `includeComments` | `false` | Send every comment to Jev. See [Comments](#comments). |
813
+ | `reads` | `["workspacePackages", "symbols", "references"]` | What Jev reads beside each file's code, for every rule. `[]` is the code alone. See [What Jev reads](#what-jev-reads). |
814
+ | `maxSymbolDocumentationLength` | `256` | Maximum characters of each symbol's combined documentation and JSDoc tags, before `maxSymbolLength` applies. A non-negative integer; `0` omits them. |
815
+ | `maxSymbolLength` | `256` | Maximum characters of each complete `symbols[name]` string, including type signatures and documentation. A non-negative integer; `0` leaves names with empty content. |
816
+ | `provider` | TypeSafe's Jev | Where the questions go instead, with its own model. See [Providers](#providers). |
782
817
 
783
818
  - An invalid shape refuses the run.
784
819
  - A config can import other files by relative path, but no packages besides
@@ -866,9 +901,10 @@ export default defineConfig({ provider: mine });
866
901
  `choice`'s key. An id left out goes unanswered: its rule is not reported, and
867
902
  is asked again on the next run.
868
903
  - A rejection stops the run, but for three errors adhere exports:
869
- `ContextOverflow` skips the file, `RequestBlocked` reports it blocked, and
870
- `RequestFailed` is tried again, up to three times, when its `status` is 0,
871
- for no answer, or a 408, 429, or 5xx.
904
+ `ContextOverflow` gets one retry without `references` when that map is present;
905
+ if the request still overflows, the file is skipped. `RequestBlocked` reports
906
+ it blocked, and `RequestFailed` is tried again, up to three times, when its
907
+ `status` is 0, for no answer, or a 408, 429, or 5xx.
872
908
  - adhere paces calls with `--rpm`, aborts `signal` after 30 seconds, and splits
873
909
  requests to fit Jev's 64,000 tokens. A model that takes less splits further in
874
910
  its `ask`.
@@ -1422,10 +1458,13 @@ about three bytes a token, and leaves 1k of room for what Jev wraps around it.
1422
1458
 
1423
1459
  - When a file's questions would not fit in one request, they are split across
1424
1460
  several.
1425
- - A file whose state and longest question would not fit together, or that has
1426
- more than 255 sections, is skipped and counted in the summary, and the rest of
1427
- the run goes on. What adhere reads is never cut short to fit, so large
1428
- `references` can skip a file.
1461
+ - If a file's state and longest question would not fit together, or a provider
1462
+ reports a context overflow, adhere retries once without `references` when that
1463
+ map is present. Target code, `symbols`, and other context remain intact; no
1464
+ declaration excerpt is shortened to fit. Removing references may still leave
1465
+ the request too large, in which case the file is skipped and counted in the
1466
+ summary. A file with more than 255 code sections is also skipped, and the rest
1467
+ of the run goes on.
1429
1468
  - So is a file Jev itself counts as over its context, which text denser than the
1430
1469
  estimate, such as CJK, can cause.
1431
1470
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.17.5",
3
+ "version": "0.18.0",
4
4
  "private": false,
5
5
  "description": "Lints a repository against the rules in .adhere/, judged by Jev.",
6
6
  "license": "MIT",
@@ -90,10 +90,10 @@
90
90
  "--force"
91
91
  ],
92
92
  "optionalDependencies": {
93
- "@drkmttr/adhere-darwin-arm64": "0.17.5",
94
- "@drkmttr/adhere-darwin-x64": "0.17.5",
95
- "@drkmttr/adhere-linux-arm64": "0.17.5",
96
- "@drkmttr/adhere-linux-x64": "0.17.5",
97
- "@drkmttr/adhere-win32-x64": "0.17.5"
93
+ "@drkmttr/adhere-darwin-arm64": "0.18.0",
94
+ "@drkmttr/adhere-darwin-x64": "0.18.0",
95
+ "@drkmttr/adhere-linux-arm64": "0.18.0",
96
+ "@drkmttr/adhere-linux-x64": "0.18.0",
97
+ "@drkmttr/adhere-win32-x64": "0.18.0"
98
98
  }
99
99
  }
package/src/config.ts CHANGED
@@ -222,6 +222,10 @@ export const AdhereConfig = Schema.Struct({
222
222
  ),
223
223
  /** Shared context for every audited file and all its rules. Unset: all built-in reads; []: code only. */
224
224
  reads: Schema.optionalKey(Reads),
225
+ /** Maximum characters of each symbol's complete resolved content. Defaults to 256. */
226
+ maxSymbolLength: Schema.optionalKey(Schema.Natural),
227
+ /** Maximum characters of documentation and JSDoc tags per symbol. Defaults to 256; 0 omits them. */
228
+ maxSymbolDocumentationLength: Schema.optionalKey(Schema.Natural),
225
229
  /**
226
230
  * A record of rules, or a directory path relative to the config file,
227
231
  * which holds its rules as `.adhere/rules/` does. Unset: the rules in
@@ -330,6 +334,8 @@ export const DEFAULT_MODEL = "jev-latest";
330
334
  export const DEFAULT_THRESHOLD = 0.8;
331
335
  export const DEFAULT_SUFFICIENCY_THRESHOLD = 0.6;
332
336
  export const DEFAULT_READS: typeof Reads.Type = ["workspacePackages", "symbols", "references"];
337
+ export const DEFAULT_MAX_SYMBOL_DOCUMENTATION_LENGTH = 256;
338
+ export const DEFAULT_MAX_SYMBOL_LENGTH = 256;
333
339
 
334
340
  /** A config with its presets folded in and every default applied. */
335
341
  export interface ResolvedConfig {
@@ -349,6 +355,10 @@ export interface ResolvedConfig {
349
355
  readonly includeComments?: boolean;
350
356
  /** Built-in context shared by every rule, with the default applied. */
351
357
  readonly reads: typeof Reads.Type;
358
+ /** Maximum characters of each symbol's complete resolved content. */
359
+ readonly maxSymbolLength: number;
360
+ /** Maximum characters of documentation and JSDoc tags before the complete symbol limit applies. */
361
+ readonly maxSymbolDocumentationLength: number;
352
362
  /**
353
363
  * The workspace's packages, each name with its directory from the working
354
364
  * directory, read from its manifests on every run when the config's reads
@@ -411,6 +421,9 @@ export const resolveConfig = (
411
421
  ...(config.exclude === undefined ? {} : { exclude: config.exclude }),
412
422
  ...(config.includeComments === undefined ? {} : { includeComments: config.includeComments }),
413
423
  reads: config.reads ?? DEFAULT_READS,
424
+ maxSymbolLength: config.maxSymbolLength ?? DEFAULT_MAX_SYMBOL_LENGTH,
425
+ maxSymbolDocumentationLength:
426
+ config.maxSymbolDocumentationLength ?? DEFAULT_MAX_SYMBOL_DOCUMENTATION_LENGTH,
414
427
  };
415
428
  };
416
429
 
@@ -14,6 +14,7 @@ import { Credentials } from "#services/Credentials.ts";
14
14
  import {
15
15
  type Body,
16
16
  type ComparedRule,
17
+ describeState,
17
18
  edgeKey,
18
19
  conflictBody,
19
20
  contradictBody,
@@ -36,6 +37,7 @@ import {
36
37
  type Rules,
37
38
  sufficiencyKey,
38
39
  tokensOf,
40
+ logRequestPayload,
39
41
  } from "#services/Jev.ts";
40
42
  import {
41
43
  Duration,
@@ -87,7 +89,7 @@ const hooked = (
87
89
  return Effect.tryPromise({
88
90
  try: async () => ({
89
91
  ...body,
90
- state: { ...body.state, ...(await appendState(body.state, file, bun)) },
92
+ state: describeState({ ...body.state, ...(await appendState(body.state, file, bun)) }),
91
93
  }),
92
94
  catch: (cause) => refused(`the appendState of ${id} failed: ${messageOf(cause)}`),
93
95
  });
@@ -190,6 +192,7 @@ export const JevLive = Layer.effect(Jev)(
190
192
  const limiter = yield* RateLimiter.RateLimiter;
191
193
 
192
194
  const provider: Provider = config.provider ?? jev({ model: config.model, fetch });
195
+ const previewed = new Set<string>();
193
196
  // The key it asks with, when it names one `adhere login` saves. Read when
194
197
  // a request needs it, not in the layer: a run with nothing pending needs none.
195
198
  const apiKey: Effect.Effect<string | undefined, JevUnavailable> =
@@ -226,8 +229,23 @@ export const JevLive = Layer.effect(Jev)(
226
229
  yield* Effect.logDebug(
227
230
  `${kind}: asking ${provider.model} ${questions} ${questions === 1 ? "question" : "questions"}, about ${tokensOf(body)} tokens`,
228
231
  );
232
+ if ((kind === "judge" || kind === "locate") && !previewed.has(kind)) {
233
+ previewed.add(kind);
234
+ yield* logRequestPayload(
235
+ `${kind}: first ${kind === "locate" ? "match-to-lines " : ""}request payload`,
236
+ () => body,
237
+ "Debug",
238
+ );
239
+ }
229
240
  const [elapsed, answered] = yield* Effect.timed(
230
- askProvider(provider, body.state, questionsOf(body.questions), Answers, key, pace),
241
+ askProvider(provider, body.state, questionsOf(body.questions), Answers, key, pace).pipe(
242
+ Effect.tapErrorTag("JevOverflow", () =>
243
+ logRequestPayload(
244
+ `${kind}: too long request payload (provider overflow)`,
245
+ () => body,
246
+ ),
247
+ ),
248
+ ),
231
249
  );
232
250
  yield* Effect.logDebug(
233
251
  `${kind}: ${provider.model} answered in ${Math.round(Duration.toMillis(elapsed))} ms`,
@@ -1,4 +1,4 @@
1
- import { decodeConfig, resolveConfig, type Rule, type Rules } from "#config.ts";
1
+ import { decodeConfig, resolveConfig, type JevState, type Rule, type Rules } from "#config.ts";
2
2
  import {
3
3
  type AskOptions,
4
4
  ContextOverflow,
@@ -12,8 +12,9 @@ import {
12
12
  import { AdhereConfig } from "#services/AdhereConfig.ts";
13
13
  import { Credentials } from "#services/Credentials.ts";
14
14
  import { JevLive } from "#services/Jev.http.ts";
15
- import { Jev } from "#services/Jev.ts";
16
- import { Effect, Layer, Redacted } from "effect";
15
+ import { describeState, Jev, judgeBody, locateBody, requestsOf } from "#services/Jev.ts";
16
+ import { questionsOf, requestOf, type SystemOneRequest } from "#providers/systemOne.ts";
17
+ import { Effect, Layer, Logger, Redacted, References } from "effect";
17
18
  import { FetchHttpClient } from "effect/http";
18
19
  import { describe, expect, it } from "vite-plus/test";
19
20
 
@@ -25,6 +26,12 @@ const named: Rule = {
25
26
  const lines = ["const port = 3000;"];
26
27
  const file = { path: "/repo/src/server.ts", contents: lines.join("\n") };
27
28
 
29
+ // Restore JSON escapes only inside string tokens, leaving layout and source backslashes intact.
30
+ const jsonFromDiagnostic = (text: string): string =>
31
+ text.replace(/"(?:\\[\s\S]|[^"\\])*"/g, (token) =>
32
+ token.replace(/\n/g, "\\n").replace(/\t/g, "\\t"),
33
+ );
34
+
28
35
  /**
29
36
  * Jev under a config whose provider answers: any request Jev's own provider
30
37
  * sends, or any read of its key, dies. The saved OpenAI key is "sk-saved".
@@ -38,10 +45,13 @@ const judged = (
38
45
  ask,
39
46
  ...(savedKey === undefined ? {} : { savedKey }),
40
47
  },
48
+ kind: "judge" | "locate" = "judge",
41
49
  ) =>
42
50
  Effect.gen(function* () {
43
51
  const jev = yield* Jev;
44
- return yield* jev.judge(lines, rules, [], file);
52
+ return kind === "judge"
53
+ ? yield* jev.judge(lines, rules, [], file)
54
+ : yield* jev.locate(lines, rules, file);
45
55
  }).pipe(
46
56
  Effect.provide(
47
57
  JevLive.pipe(
@@ -117,6 +127,272 @@ describe("a config's provider", () => {
117
127
  expect(blocked).toMatchObject({ _tag: "JevBlocked", ray: "ref-7" });
118
128
  });
119
129
 
130
+ it.each([
131
+ ["All", "judge"],
132
+ ["Trace", "judge"],
133
+ ["Debug", "judge"],
134
+ ["All", "locate"],
135
+ ["Trace", "locate"],
136
+ ["Debug", "locate"],
137
+ ] as const)(
138
+ "logs split requests after appendState with line criteria only at All (minimum: %s, kind: %s)",
139
+ async (minimum, kind) => {
140
+ const context = `export function caller() {\n\tconst escaped = "\\n\\t";\n\treturn "${"hook-context".repeat(40_000)}";\n}`;
141
+ const added = { references: { "port:caller.ts": context } };
142
+ const hooked: Rule = {
143
+ ...plain,
144
+ appliesTo: ["a server"],
145
+ appendState: async () => added,
146
+ };
147
+ const body = (
148
+ kind === "judge"
149
+ ? judgeBody("clef-flash", lines, { plain: hooked })
150
+ : locateBody("clef-flash", lines, { plain: hooked })
151
+ ) as SystemOneRequest & { readonly state: JevState };
152
+ const requests = requestsOf({ ...body, state: describeState({ ...body.state, ...added }) });
153
+ expect(requests.length).toBeGreaterThan(1);
154
+ const request = requests[0]!;
155
+ const asked: Array<{
156
+ state: State;
157
+ questions: ReadonlyArray<Question>;
158
+ apiKey: string | undefined;
159
+ }> = [];
160
+ let originalBody = "";
161
+ const originalAdded = JSON.stringify(added);
162
+ const logs: Array<Logger.Options<unknown>> = [];
163
+ const overflowed = await Effect.runPromise(
164
+ Effect.flip(
165
+ judged(
166
+ async (state, questions, { apiKey }) => {
167
+ asked.push({ state, questions, apiKey });
168
+ originalBody = JSON.stringify(requestOf("clef-flash", state, questions));
169
+ throw new ContextOverflow("too long");
170
+ },
171
+ "openai",
172
+ { plain: hooked },
173
+ undefined,
174
+ kind,
175
+ ),
176
+ ).pipe(
177
+ Effect.provide(Logger.layer([Logger.make((entry) => logs.push(entry))])),
178
+ Effect.provideService(References.MinimumLogLevel, minimum),
179
+ ),
180
+ );
181
+ expect(overflowed._tag).toBe("JevOverflow");
182
+ expect(asked).toEqual([
183
+ { state: request.state, questions: questionsOf(request.questions), apiKey: "sk-saved" },
184
+ ]);
185
+ const messages = logs.flatMap(({ message }) =>
186
+ Array.isArray(message) ? message : [message],
187
+ );
188
+ const oversized = messages.filter((message) =>
189
+ String(message).includes("too long request payload"),
190
+ );
191
+ if (minimum !== "Debug") {
192
+ const label = `${kind}: too long request payload (provider overflow)\n`;
193
+ expect(oversized).toHaveLength(1);
194
+ expect(String(oversized[0]).startsWith(label)).toBe(true);
195
+ const json = jsonFromDiagnostic(String(oversized[0]).slice(label.length));
196
+ const logged = JSON.parse(json) as typeof request;
197
+ const id = kind === "judge" ? "plain" : "start:plain:1";
198
+ expect(Object.keys(request.questions)).toEqual([id]);
199
+ const question = request.questions[id]!;
200
+ expect(question.instructions).toMatchObject({ rule: plain.description, must: plain.must });
201
+ if (minimum === "Trace" && question.type === "choice") {
202
+ const { criteria, ...withoutCriteria } = question;
203
+ expect(criteria).toEqual({ "1": lines[0] });
204
+ expect(logged.questions[id]).not.toHaveProperty("criteria");
205
+ expect(logged).toEqual({ ...request, questions: { [id]: withoutCriteria } });
206
+ expect(json).toBe(JSON.stringify(logged, null, 2));
207
+ } else {
208
+ expect(json).toBe(JSON.stringify(request, null, 2));
209
+ expect(logged).toEqual(request);
210
+ expect(logged.questions[id]?.criteria).toBeDefined();
211
+ }
212
+ expect(logged.state).toMatchObject(added);
213
+ if (minimum === "Trace") expect(Object.keys(logged.state)[0]).toBe("code");
214
+ expect(logged.model).toBe("clef-flash");
215
+ expect(
216
+ logs.find(({ message }) => Array.isArray(message) && message.includes(oversized[0]))
217
+ ?.logLevel,
218
+ ).toBe("Trace");
219
+ } else {
220
+ expect(oversized).toEqual([]);
221
+ const label = `${kind}: first ${kind === "locate" ? "match-to-lines " : ""}request payload\n`;
222
+ const previews = messages.filter((message) => String(message).startsWith(label));
223
+ expect(previews).toHaveLength(1);
224
+ const logged = JSON.parse(
225
+ jsonFromDiagnostic(String(previews[0]).slice(label.length)),
226
+ ) as SystemOneRequest;
227
+ expect(logged.state).toEqual(request.state);
228
+ expect(Object.keys(logged.state)[0]).toBe("code");
229
+ expect(logged.model).toBe(request.model);
230
+ const id = kind === "judge" ? "plain" : "start:plain:1";
231
+ if (kind === "locate") {
232
+ const { criteria, ...question } = request.questions[id]!;
233
+ expect(criteria).toBeDefined();
234
+ expect(logged.questions).toEqual({ [id]: question });
235
+ } else {
236
+ expect(logged.questions).toEqual(request.questions);
237
+ }
238
+ }
239
+ for (const message of messages.filter((message) =>
240
+ String(message).includes("request payload"),
241
+ )) {
242
+ expect(String(message)).toContain("\n\tconst escaped = ");
243
+ expect(String(message)).toContain(String.raw`\\n\\t`);
244
+ expect(String(message)).not.toContain(String.raw`\n\tconst escaped`);
245
+ }
246
+ expect(originalBody).toContain(String.raw`\n\tconst escaped`);
247
+ expect(JSON.stringify(added)).toBe(originalAdded);
248
+ const sent = asked[0]!;
249
+ expect(JSON.stringify(requestOf("clef-flash", sent.state, sent.questions))).toBe(
250
+ originalBody,
251
+ );
252
+ expect(JSON.parse(originalBody)).toEqual(request);
253
+ expect(JSON.stringify(messages)).not.toContain("sk-saved");
254
+ expect(request).not.toHaveProperty("apiKey");
255
+ expect(request).not.toHaveProperty("signal");
256
+ expect(messages).toContain("attempt failed: too long");
257
+ },
258
+ );
259
+
260
+ it.each(["All", "Trace", "Debug", "Info"] as const)(
261
+ "previews only the first judge and match-to-lines request per shared JevLive (minimum: %s)",
262
+ async (minimum) => {
263
+ const context = `export function caller() {\n\tconst escaped = "\\n\\t";\n\treturn "${"preview-context".repeat(40_000)}";\n}`;
264
+ const added = { references: { "port:caller.ts": context } };
265
+ const readState = { workspacePackages: { server: "src" } };
266
+ const hookedRules = {
267
+ plain: { ...plain, appliesTo: ["a server"], appendState: async () => added },
268
+ named: { ...named, appliesTo: ["a server"], appendState: async () => added },
269
+ };
270
+ const files = [file, { path: "/repo/src/other.ts", contents: " const port = 8080;" }, file];
271
+ const expected = files.flatMap((current) => {
272
+ const code = current.contents.split("\n");
273
+ return ["judge", "locate"].flatMap((kind) =>
274
+ Object.entries(hookedRules).flatMap(([id, rule]) => {
275
+ const body = (
276
+ kind === "judge"
277
+ ? judgeBody("clef-flash", code, { [id]: rule }, [], readState)
278
+ : locateBody("clef-flash", code, { [id]: rule }, readState)
279
+ ) as SystemOneRequest & { readonly state: JevState };
280
+ const split = requestsOf({
281
+ ...body,
282
+ state: describeState({ ...body.state, ...added }),
283
+ });
284
+ expect(split.length).toBeGreaterThan(1);
285
+ return split;
286
+ }),
287
+ );
288
+ });
289
+ const firstJudge = expected[0]!;
290
+ const firstLocate = expected.find((body) =>
291
+ Object.keys(body.questions)[0]?.startsWith("start:"),
292
+ )!;
293
+ const asked: Array<SystemOneRequest> = [];
294
+ const snapshots: string[] = [];
295
+ const keys: Array<string | undefined> = [];
296
+ const logs: Array<Logger.Options<unknown>> = [];
297
+ const provider: Provider = {
298
+ model: "clef-flash",
299
+ savedKey: "openai",
300
+ ask: async (state, questions, { apiKey }) => {
301
+ const body = requestOf("clef-flash", state, questions);
302
+ asked.push(body);
303
+ snapshots.push(JSON.stringify(body));
304
+ keys.push(apiKey);
305
+ if (asked.length === 1) throw new RequestFailed("retry first request", 503);
306
+ return { answers: {} };
307
+ },
308
+ };
309
+ await Effect.runPromise(
310
+ Effect.gen(function* () {
311
+ const jev = yield* Jev;
312
+ for (const current of files) {
313
+ const code = current.contents.split("\n");
314
+ const judged = yield* jev.judge(code, hookedRules, [], current, readState);
315
+ expect(judged.probabilities).toEqual({});
316
+ expect(yield* jev.locate(code, hookedRules, current, readState)).toEqual({});
317
+ }
318
+ }).pipe(
319
+ Effect.provide(
320
+ JevLive.pipe(
321
+ Layer.provide([
322
+ Layer.succeed(FetchHttpClient.Fetch, (() => {
323
+ throw new Error("preview tests send no HTTP requests");
324
+ }) as unknown as typeof globalThis.fetch),
325
+ Layer.succeed(AdhereConfig, resolveConfig({ presets: [], rules: {}, provider })),
326
+ Layer.succeed(Credentials, {
327
+ key: () => Effect.succeed(Redacted.make("sk-saved")),
328
+ file: Effect.die("unused"),
329
+ save: () => Effect.die("unused"),
330
+ remove: () => Effect.die("unused"),
331
+ }),
332
+ ]),
333
+ ),
334
+ ),
335
+ Effect.provide(Logger.layer([Logger.make((entry) => logs.push(entry))])),
336
+ Effect.provideService(References.MinimumLogLevel, minimum),
337
+ ),
338
+ );
339
+ expect(asked).toEqual([firstJudge, ...expected]);
340
+ expect(asked.map((body) => JSON.stringify(body))).toEqual(snapshots);
341
+ expect(snapshots[0]).toContain(String.raw`\n\tconst escaped`);
342
+ expect(snapshots.map((json) => JSON.parse(json))).toEqual(asked);
343
+ expect(keys).toEqual(asked.map(() => "sk-saved"));
344
+ const messages = logs.flatMap(({ message }) =>
345
+ Array.isArray(message) ? message : [message],
346
+ );
347
+ const previews = messages.filter((message) => String(message).includes("request payload"));
348
+ if (minimum === "Info") {
349
+ expect(previews).toEqual([]);
350
+ expect(JSON.stringify(messages)).not.toContain("preview-context");
351
+ } else {
352
+ const labels = [
353
+ "judge: first request payload\n",
354
+ "locate: first match-to-lines request payload\n",
355
+ ];
356
+ expect(previews).toHaveLength(2);
357
+ for (const [index, body] of [firstJudge, firstLocate].entries()) {
358
+ const label = labels[index]!;
359
+ expect(String(previews[index]).startsWith(label)).toBe(true);
360
+ const text = String(previews[index]).slice(label.length);
361
+ expect(text).toContain("\n\tconst escaped = ");
362
+ expect(text).toContain(String.raw`\\n\\t`);
363
+ expect(text).not.toContain(String.raw`\n\tconst escaped`);
364
+ const json = jsonFromDiagnostic(text);
365
+ const logged = JSON.parse(json) as SystemOneRequest;
366
+ expect(logged.model).toBe(body.model);
367
+ expect(logged.state).toEqual(body.state);
368
+ expect(logged.state).toMatchObject({ ...readState, ...added });
369
+ if (minimum === "All") {
370
+ expect(json).toBe(JSON.stringify(body, null, 2));
371
+ expect(logged).toEqual(body);
372
+ } else {
373
+ expect(Object.keys(logged.state)[0]).toBe("code");
374
+ if (index === 0) {
375
+ expect(logged.questions).toEqual(body.questions);
376
+ expect(logged.questions.plain?.criteria).toBeDefined();
377
+ } else {
378
+ const { criteria, ...question } = body.questions["start:plain:1"]!;
379
+ expect(criteria).toEqual({ "1": lines[0] });
380
+ expect(logged.questions).toEqual({ "start:plain:1": question });
381
+ }
382
+ }
383
+ expect(
384
+ logs.find(({ message }) => Array.isArray(message) && message.includes(previews[index]))
385
+ ?.logLevel,
386
+ ).toBe("Debug");
387
+ }
388
+ }
389
+ expect(JSON.stringify(messages)).not.toContain("sk-saved");
390
+ expect(messages.some((message) => String(message).includes("too long request payload"))).toBe(
391
+ false,
392
+ );
393
+ },
394
+ );
395
+
120
396
  it("is asked as a method, so a class can implement it", async () => {
121
397
  class Fixed implements Provider {
122
398
  readonly model = "fixed";