@drkmttr/adhere 0.15.0 → 0.17.5
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 +273 -130
- package/bin/adhere.js +19 -4
- package/package.json +34 -12
- package/skills/adhere/SKILL.md +180 -31
- package/skills/adhere-setup/SKILL.md +142 -16
- package/src/cli.ts +56 -23
- package/src/config.ts +49 -16
- package/src/index.ts +14 -0
- package/src/markdown.ts +9 -5
- package/src/providers/cloudflare.ts +84 -0
- package/src/providers/jev.ts +66 -0
- package/src/providers/openai.ts +89 -0
- package/src/providers/post.ts +53 -0
- package/src/providers/provider.ts +131 -0
- package/src/providers/providers.test.ts +276 -0
- package/src/providers/systemOne.ts +95 -0
- package/src/services/AdhereConfig.ts +2 -4
- package/src/services/Credentials.ts +101 -51
- package/src/services/Jev.http.ts +165 -170
- package/src/services/Jev.provider.test.ts +207 -0
- package/src/services/Jev.reads.test.ts +439 -0
- package/src/services/Jev.ts +205 -113
- package/src/services/NativeLsp.ts +289 -0
- package/src/services/SourceReads.test.ts +1040 -0
- package/src/services/SourceReads.ts +437 -0
- package/src/services/SourceWalker.ts +26 -59
- package/src/workflows/audit.ts +440 -265
- package/src/workflows/format.ts +74 -17
- package/src/workspaces.test.ts +73 -0
- package/src/workspaces.ts +91 -0
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { AdhereConfig, AdhereConfigLive } from "#services/AdhereConfig.ts";
|
|
2
2
|
import { AuditCacheLive } from "#services/AuditCache.ts";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Credentials,
|
|
5
|
+
CredentialsLive,
|
|
6
|
+
CredentialsUnavailable,
|
|
7
|
+
KEYS,
|
|
8
|
+
} from "#services/Credentials.ts";
|
|
4
9
|
import { JevLive } from "#services/Jev.http.ts";
|
|
10
|
+
import { SourceReadsLive } from "#services/SourceReads.ts";
|
|
5
11
|
import { SourceWalkerLive } from "#services/SourceWalker.ts";
|
|
6
12
|
import { Status } from "#services/Status.ts";
|
|
7
13
|
import {
|
|
@@ -26,6 +32,7 @@ import { type Install, initProject } from "#init.ts";
|
|
|
26
32
|
import { installRules, listRules, parseSource } from "#install.ts";
|
|
27
33
|
import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
|
|
28
34
|
import { type PresetName, presetNames } from "#presets.ts";
|
|
35
|
+
import type { SavedKey } from "#providers/provider.ts";
|
|
29
36
|
import { globalRuleSet } from "#rules.ts";
|
|
30
37
|
import { formatStrays, straysAmong } from "#wording.ts";
|
|
31
38
|
import {
|
|
@@ -44,7 +51,6 @@ import {
|
|
|
44
51
|
Stream,
|
|
45
52
|
} from "effect";
|
|
46
53
|
import { Argument, Command, Flag, Prompt } from "effect/cli";
|
|
47
|
-
import { FetchHttpClient } from "effect/http";
|
|
48
54
|
// A Bun text import (https://bun.sh/docs/bundler/loaders#text): the file's
|
|
49
55
|
// contents become a string at bundle time, so the compiled binary carries the
|
|
50
56
|
// skill without an --asset flag. `skills/` sits beside `src/`, outside the `#`
|
|
@@ -110,12 +116,12 @@ const sufficiencyThreshold = Flag.Finite("sufficiency-threshold").pipe(
|
|
|
110
116
|
|
|
111
117
|
const limit = Flag.Int("limit").pipe(
|
|
112
118
|
Flag.filter(
|
|
113
|
-
(
|
|
114
|
-
(
|
|
119
|
+
(files) => files >= 0,
|
|
120
|
+
(files) => `--limit is a number of files, 0 or more, not ${files}.`,
|
|
115
121
|
),
|
|
116
122
|
Flag.optional,
|
|
117
123
|
Flag.withDescription(
|
|
118
|
-
"
|
|
124
|
+
"Process at most this many files needing requests, in path order, including every pending rule per selected file. Fully cached and located files do not count. A file can send multiple requests. --limit 0 shows the plan without resolving native reads or sending requests.",
|
|
119
125
|
),
|
|
120
126
|
);
|
|
121
127
|
|
|
@@ -221,8 +227,9 @@ const force = Flag.Boolean("force").pipe(
|
|
|
221
227
|
export const auditLayer = (flags: Flags, filter: ReadonlyArray<string> = []) =>
|
|
222
228
|
Layer.mergeAll(
|
|
223
229
|
SourceWalkerLive(filter),
|
|
230
|
+
SourceReadsLive(),
|
|
224
231
|
AuditCacheLive,
|
|
225
|
-
JevLive.pipe(Layer.provide(
|
|
232
|
+
JevLive.pipe(Layer.provide(CredentialsLive)),
|
|
226
233
|
).pipe(Layer.provideMerge(AdhereConfigLive(flags)), Layer.provideMerge(statusLayer));
|
|
227
234
|
|
|
228
235
|
/** `adhere lint`: the audit. */
|
|
@@ -235,10 +242,19 @@ export const lintCommand = Command.make(
|
|
|
235
242
|
const plan = yield* planAudit({ limit: Option.getOrUndefined(input.limit) });
|
|
236
243
|
const limits = { filter: input.filter, rpm: Option.getOrUndefined(input.rpm) };
|
|
237
244
|
yield* toStderr(`${describePlan(plan, limits).join("\n")}\n`);
|
|
245
|
+
if (plan.planOnly === true) {
|
|
246
|
+
// Planning still knows the live content keys, so a complete walk can prune without sending.
|
|
247
|
+
if (input.filter.length === 0) yield* pruneCache(plan);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
238
250
|
// A prompt draws on stdout, so it needs a terminal at both ends: none when
|
|
239
251
|
// the report is redirected, or in CI.
|
|
240
252
|
const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
|
|
241
|
-
if (
|
|
253
|
+
if (
|
|
254
|
+
(plan.requests > 0 || (plan.cachedLocateRequests ?? 0) > 0) &&
|
|
255
|
+
interactive &&
|
|
256
|
+
!input.yes
|
|
257
|
+
) {
|
|
242
258
|
const send = yield* Prompt.run(
|
|
243
259
|
Prompt.Confirm({ message: sendQuestion(plan), initial: true }),
|
|
244
260
|
);
|
|
@@ -263,6 +279,7 @@ export const lintCommand = Command.make(
|
|
|
263
279
|
root: path.resolve(),
|
|
264
280
|
threshold: plan.threshold,
|
|
265
281
|
sufficiencyThreshold: plan.sufficiencyThreshold,
|
|
282
|
+
...(plan.provider === undefined ? {} : { provider: plan.provider }),
|
|
266
283
|
}).join("\n"),
|
|
267
284
|
);
|
|
268
285
|
// Only errors fail the run, unless --deny-warnings: a warning is for a nit or a noisy rule.
|
|
@@ -290,10 +307,10 @@ export const lintCommand = Command.make(
|
|
|
290
307
|
|
|
291
308
|
/** Jev, with the key `lint` uses, and the config it reads the model and threshold from. */
|
|
292
309
|
export const validateLayer = (flags: Flags) =>
|
|
293
|
-
Layer.merge(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
)
|
|
310
|
+
Layer.merge(JevLive.pipe(Layer.provide(CredentialsLive)), AuditCacheLive).pipe(
|
|
311
|
+
Layer.provideMerge(AdhereConfigLive(flags)),
|
|
312
|
+
Layer.provideMerge(statusLayer),
|
|
313
|
+
);
|
|
297
314
|
|
|
298
315
|
/**
|
|
299
316
|
* `adhere validate`: everything `lint` loads, then how each rule's wording
|
|
@@ -439,10 +456,10 @@ export const installCommand = Command.make(
|
|
|
439
456
|
* The key typed at a masked prompt, or piped in: `adhere login < key.txt`.
|
|
440
457
|
* Whitespace around it, like the newline a pipe ends with, is dropped.
|
|
441
458
|
*/
|
|
442
|
-
const readApiKey = Effect.fn("login.readApiKey")(function* () {
|
|
459
|
+
const readApiKey = Effect.fn("login.readApiKey")(function* (label: string) {
|
|
443
460
|
const stdio = yield* Stdio.Stdio;
|
|
444
461
|
const typed: string = (yield* stdio.stdinIsTerminal)
|
|
445
|
-
? Redacted.value(yield* Prompt.run(Prompt.Password({ message:
|
|
462
|
+
? Redacted.value(yield* Prompt.run(Prompt.Password({ message: label })))
|
|
446
463
|
: yield* stdio.stdin.pipe(
|
|
447
464
|
Stream.decodeText(),
|
|
448
465
|
Stream.mkString,
|
|
@@ -461,33 +478,49 @@ const readApiKey = Effect.fn("login.readApiKey")(function* () {
|
|
|
461
478
|
return Redacted.make(key);
|
|
462
479
|
});
|
|
463
480
|
|
|
464
|
-
/**
|
|
465
|
-
|
|
481
|
+
/** `--openai`: the OpenAI key, for the `openai` provider, in place of TypeSafe AI's. */
|
|
482
|
+
const openaiKey = Flag.Boolean("openai").pipe(
|
|
483
|
+
Flag.withDefault(false),
|
|
484
|
+
Flag.withDescription(
|
|
485
|
+
"The OpenAI API key, for a config whose provider is openai(), in place of the TypeSafe AI key Jev asks with.",
|
|
486
|
+
),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
/** Which saved key a command means, and how it names it: TypeSafe AI's by default. */
|
|
490
|
+
const keyOf = (openai: boolean) => {
|
|
491
|
+
const name: SavedKey = openai ? "openai" : "typesafe";
|
|
492
|
+
return { name, ...KEYS[name] };
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/** `adhere login`: save an API key, so a run needs no TYPESAFE_API_KEY, or with `--openai`, no OPENAI_API_KEY. */
|
|
496
|
+
export const loginCommand = Command.make("login", { openai: openaiKey }, ({ openai }) =>
|
|
466
497
|
Effect.gen(function* () {
|
|
498
|
+
const { name, label, said } = keyOf(openai);
|
|
467
499
|
const credentials = yield* Credentials;
|
|
468
|
-
yield* credentials.save(yield* readApiKey());
|
|
469
|
-
yield* Console.log(`Saved the
|
|
500
|
+
yield* credentials.save(name, yield* readApiKey(label));
|
|
501
|
+
yield* Console.log(`Saved the ${said} to ${yield* credentials.file}.`);
|
|
470
502
|
}),
|
|
471
503
|
).pipe(
|
|
472
504
|
Command.withDescription(
|
|
473
|
-
"Save a TypeSafe AI API key in ~/.config/adhere ($XDG_CONFIG_HOME/adhere when set), readable only by you, so lint runs without TYPESAFE_API_KEY. Prompts for the key, or reads it from stdin when piped.",
|
|
505
|
+
"Save a TypeSafe AI API key in ~/.config/adhere ($XDG_CONFIG_HOME/adhere when set), readable only by you, so lint runs without TYPESAFE_API_KEY. With --openai, save an OpenAI API key beside it instead, for the openai provider. Prompts for the key, or reads it from stdin when piped.",
|
|
474
506
|
),
|
|
475
507
|
Command.provide(CredentialsLive),
|
|
476
508
|
);
|
|
477
509
|
|
|
478
|
-
/** `adhere logout`: delete
|
|
479
|
-
export const logoutCommand = Command.make("logout", {}, () =>
|
|
510
|
+
/** `adhere logout`: delete a saved API key. */
|
|
511
|
+
export const logoutCommand = Command.make("logout", { openai: openaiKey }, ({ openai }) =>
|
|
480
512
|
Effect.gen(function* () {
|
|
513
|
+
const { name, said } = keyOf(openai);
|
|
481
514
|
const credentials = yield* Credentials;
|
|
482
515
|
const file = yield* credentials.file;
|
|
483
|
-
const removed = yield* credentials.remove;
|
|
516
|
+
const removed = yield* credentials.remove(name);
|
|
484
517
|
yield* Console.log(
|
|
485
|
-
removed ? `Deleted the
|
|
518
|
+
removed ? `Deleted the ${said} saved in ${file}.` : `No ${said} is saved in ${file}.`,
|
|
486
519
|
);
|
|
487
520
|
}),
|
|
488
521
|
).pipe(
|
|
489
522
|
Command.withDescription(
|
|
490
|
-
"Delete the API key adhere login saved. TYPESAFE_API_KEY, when set, still applies.",
|
|
523
|
+
"Delete the API key adhere login saved, or with --openai, the OpenAI one. TYPESAFE_API_KEY or OPENAI_API_KEY, when set, still applies.",
|
|
491
524
|
),
|
|
492
525
|
Command.provide(CredentialsLive),
|
|
493
526
|
);
|
package/src/config.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { type PresetName, presetNames } from "#presets.ts";
|
|
2
|
+
import { type Provider, SAVED_KEYS } from "#providers/provider.ts";
|
|
2
3
|
import type { RuleSet } from "#rules.ts";
|
|
3
|
-
import { Effect, Schema, SchemaTransformation } from "effect";
|
|
4
|
+
import { Effect, Predicate, Schema, SchemaTransformation } from "effect";
|
|
4
5
|
|
|
5
6
|
export type RuleId = string;
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
+
/** Shared context prepared for a file's requests; it does not affect cache validity. */
|
|
9
|
+
export type ReadState = Readonly<Record<string, unknown>>;
|
|
10
|
+
|
|
11
|
+
/** The file's numbered sections and the context enabled by its config. */
|
|
8
12
|
export interface JevState {
|
|
9
13
|
readonly code: Readonly<Record<string, string>>;
|
|
14
|
+
readonly [key: string]: unknown;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
/** The file a request is about, as a rule's hook gets it: its absolute path, and its text as written. */
|
|
@@ -15,6 +20,21 @@ export interface JudgedFile {
|
|
|
15
20
|
readonly contents: string;
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A config's provider, as `Provider` in `providers/provider.ts` describes it:
|
|
25
|
+
* a model, and how to ask it. Checked rather than copied, so a class keeps
|
|
26
|
+
* what its `ask` reads off `this`.
|
|
27
|
+
*/
|
|
28
|
+
const ProviderSchema = Schema.declare(
|
|
29
|
+
(value: unknown): value is Provider =>
|
|
30
|
+
Predicate.hasProperty(value, "model") &&
|
|
31
|
+
Predicate.isString(value.model) &&
|
|
32
|
+
Predicate.hasProperty(value, "ask") &&
|
|
33
|
+
Predicate.isFunction(value.ask) &&
|
|
34
|
+
(!Predicate.hasProperty(value, "savedKey") || SAVED_KEYS.some((key) => key === value.savedKey)),
|
|
35
|
+
{ expected: "a provider: a model, and a function to ask it" },
|
|
36
|
+
);
|
|
37
|
+
|
|
18
38
|
/**
|
|
19
39
|
* Bun's API, typed when the project has Bun's types. Read off `globalThis`,
|
|
20
40
|
* so this package's source type-checks in a project without them.
|
|
@@ -33,13 +53,15 @@ export type AppendState = (
|
|
|
33
53
|
) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
|
|
34
54
|
|
|
35
55
|
/**
|
|
36
|
-
* What
|
|
37
|
-
* the state carries it under, which
|
|
38
|
-
* backticks. `workspacePackages`
|
|
39
|
-
*
|
|
40
|
-
*
|
|
56
|
+
* What the config can have Jev read beside each file's code, shared by all
|
|
57
|
+
* its rules. Each name is the key the state carries it under, which a rule's
|
|
58
|
+
* description can name in backticks. `workspacePackages` maps only workspace
|
|
59
|
+
* packages imported by this file to their directories. `symbols` maps names
|
|
60
|
+
* to native compiler types, with documentation and JSDoc tags shared across variants.
|
|
61
|
+
* `references` maps used names to complete AST declaration scopes by
|
|
62
|
+
* repo-relative path, rather than fixed line windows or full related files.
|
|
41
63
|
*/
|
|
42
|
-
export const Reads = Schema.Array(Schema.Literals(["workspacePackages"]));
|
|
64
|
+
export const Reads = Schema.Array(Schema.Literals(["workspacePackages", "symbols", "references"]));
|
|
43
65
|
|
|
44
66
|
/**
|
|
45
67
|
* A rule's examples, named in RFC 2119's words, which Jev reads as written. A
|
|
@@ -83,12 +105,6 @@ const RuleFields = Schema.Struct({
|
|
|
83
105
|
* that breaks the rule is as it describes, and a yes to any drops the finding.
|
|
84
106
|
*/
|
|
85
107
|
excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
86
|
-
/**
|
|
87
|
-
* What Jev reads for the rule beside the code, by name (see `Reads`). The
|
|
88
|
-
* rule's questions go in requests of their own, so only they read it, and
|
|
89
|
-
* a change to what is read judges the rule again.
|
|
90
|
-
*/
|
|
91
|
-
reads: Schema.optionalKey(Reads),
|
|
92
108
|
/**
|
|
93
109
|
* A hook on the state Jev reads for the rule, in a rule written in
|
|
94
110
|
* TypeScript (see `AppendState`). The rule's questions go in requests of
|
|
@@ -106,6 +122,12 @@ export const Rule = Schema.Struct({
|
|
|
106
122
|
...RuleFields.fields,
|
|
107
123
|
reference: Schema.optionalKey(Schema.String),
|
|
108
124
|
avoid: Schema.optionalKey(Schema.String),
|
|
125
|
+
// Guard the removed key instead of silently dropping it as an excess property.
|
|
126
|
+
reads: Schema.optionalKey(
|
|
127
|
+
Schema.declare((_value: unknown): _value is never => false, {
|
|
128
|
+
expected: "no rule-level reads; move reads to the top level of defineConfig({...})",
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
109
131
|
})
|
|
110
132
|
.pipe(
|
|
111
133
|
Schema.decodeTo(
|
|
@@ -179,6 +201,8 @@ export const AdhereConfig = Schema.Struct({
|
|
|
179
201
|
* The model id sent to typesafe. Defaults to "jev-latest"
|
|
180
202
|
*/
|
|
181
203
|
model: Schema.optionalKey(Schema.String),
|
|
204
|
+
/** Where the questions go in place of TypeSafe's Jev, with its own model (see `Provider`). */
|
|
205
|
+
provider: Schema.optionalKey(ProviderSchema),
|
|
182
206
|
/**
|
|
183
207
|
* The minimum confidence threshold for a positive match. Defaults to 0.8
|
|
184
208
|
*/
|
|
@@ -196,6 +220,8 @@ export const AdhereConfig = Schema.Struct({
|
|
|
196
220
|
presets: Schema.Array(Schema.Literals(presetNames)).pipe(
|
|
197
221
|
Schema.withDecodingDefaultKey(Effect.succeed([])),
|
|
198
222
|
),
|
|
223
|
+
/** Shared context for every audited file and all its rules. Unset: all built-in reads; []: code only. */
|
|
224
|
+
reads: Schema.optionalKey(Reads),
|
|
199
225
|
/**
|
|
200
226
|
* A record of rules, or a directory path relative to the config file,
|
|
201
227
|
* which holds its rules as `.adhere/rules/` does. Unset: the rules in
|
|
@@ -303,10 +329,13 @@ export type Loaded<T extends { readonly rules?: unknown }> = Omit<T, "rules"> &
|
|
|
303
329
|
export const DEFAULT_MODEL = "jev-latest";
|
|
304
330
|
export const DEFAULT_THRESHOLD = 0.8;
|
|
305
331
|
export const DEFAULT_SUFFICIENCY_THRESHOLD = 0.6;
|
|
332
|
+
export const DEFAULT_READS: typeof Reads.Type = ["workspacePackages", "symbols", "references"];
|
|
306
333
|
|
|
307
334
|
/** A config with its presets folded in and every default applied. */
|
|
308
335
|
export interface ResolvedConfig {
|
|
309
336
|
readonly model: string;
|
|
337
|
+
/** Where the questions go in place of Jev; `model` is its model. */
|
|
338
|
+
readonly provider?: Provider;
|
|
310
339
|
readonly threshold: number;
|
|
311
340
|
/** Below this, a finding's sufficiency adds a warning to it. */
|
|
312
341
|
readonly sufficiencyThreshold: number;
|
|
@@ -318,9 +347,11 @@ export interface ResolvedConfig {
|
|
|
318
347
|
readonly exclude?: ReadonlyArray<string>;
|
|
319
348
|
/** Whether Jev reads every comment; unset, only the `@adhere` notes. */
|
|
320
349
|
readonly includeComments?: boolean;
|
|
350
|
+
/** Built-in context shared by every rule, with the default applied. */
|
|
351
|
+
readonly reads: typeof Reads.Type;
|
|
321
352
|
/**
|
|
322
353
|
* The workspace's packages, each name with its directory from the working
|
|
323
|
-
* directory, read from its manifests on every run
|
|
354
|
+
* directory, read from its manifests on every run when the config's reads
|
|
324
355
|
* names them.
|
|
325
356
|
*/
|
|
326
357
|
readonly workspacePackages?: Readonly<Record<string, string>>;
|
|
@@ -370,7 +401,8 @@ export const resolveConfig = (
|
|
|
370
401
|
const last = <K extends "model" | "threshold">(key: K) =>
|
|
371
402
|
applied.map((preset) => preset[key]).findLast((value) => value !== undefined);
|
|
372
403
|
return {
|
|
373
|
-
model: config.model ?? last("model") ?? DEFAULT_MODEL,
|
|
404
|
+
model: config.provider?.model ?? config.model ?? last("model") ?? DEFAULT_MODEL,
|
|
405
|
+
...(config.provider === undefined ? {} : { provider: config.provider }),
|
|
374
406
|
threshold: flags.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
|
|
375
407
|
sufficiencyThreshold:
|
|
376
408
|
flags.sufficiencyThreshold ?? config.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD,
|
|
@@ -378,6 +410,7 @@ export const resolveConfig = (
|
|
|
378
410
|
...(flags.rpm === undefined ? {} : { rpm: flags.rpm }),
|
|
379
411
|
...(config.exclude === undefined ? {} : { exclude: config.exclude }),
|
|
380
412
|
...(config.includeComments === undefined ? {} : { includeComments: config.includeComments }),
|
|
413
|
+
reads: config.reads ?? DEFAULT_READS,
|
|
381
414
|
};
|
|
382
415
|
};
|
|
383
416
|
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,20 @@ export {
|
|
|
12
12
|
type RuleSource,
|
|
13
13
|
} from "#config.ts";
|
|
14
14
|
export { findContradictions, formatContradictions, type Contradiction } from "#contradictions.ts";
|
|
15
|
+
export { cloudflare, type CloudflareOptions } from "#providers/cloudflare.ts";
|
|
16
|
+
export { jev, type JevOptions } from "#providers/jev.ts";
|
|
17
|
+
export { openai, type OpenAIOptions } from "#providers/openai.ts";
|
|
18
|
+
export {
|
|
19
|
+
type Answered,
|
|
20
|
+
type AskOptions,
|
|
21
|
+
ContextOverflow,
|
|
22
|
+
type Provider,
|
|
23
|
+
type Question,
|
|
24
|
+
RequestBlocked,
|
|
25
|
+
RequestFailed,
|
|
26
|
+
type SavedKey,
|
|
27
|
+
type State,
|
|
28
|
+
} from "#providers/provider.ts";
|
|
15
29
|
export { initProject, type InitOptions, type InitResult, type Install } from "#init.ts";
|
|
16
30
|
export { parseRuleMarkdown } from "#markdown.ts";
|
|
17
31
|
export { type PresetName, presetNames } from "#presets.ts";
|
package/src/markdown.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigUnavailable, type Example,
|
|
1
|
+
import { ConfigUnavailable, type Example, type Rule } from "#config.ts";
|
|
2
2
|
import { Effect, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -34,8 +34,7 @@ import { Effect, Schema } from "effect";
|
|
|
34
34
|
* `warning` reports the rule's findings as warnings, which do not fail a run.
|
|
35
35
|
* `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
|
|
36
36
|
* one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
|
|
37
|
-
|
|
38
|
-
* as in `reads: ["workspacePackages"]`.
|
|
37
|
+
|
|
39
38
|
*/
|
|
40
39
|
const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
|
|
41
40
|
|
|
@@ -46,7 +45,6 @@ const FrontMatter = Schema.Struct({
|
|
|
46
45
|
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
47
46
|
appliesTo: Schema.optionalKey(Matchers),
|
|
48
47
|
excludeIf: Schema.optionalKey(Matchers),
|
|
49
|
-
reads: Schema.optionalKey(Schema.fromJsonString(Reads)),
|
|
50
48
|
});
|
|
51
49
|
|
|
52
50
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
@@ -209,7 +207,13 @@ export const parseRuleMarkdown = (
|
|
|
209
207
|
message: `${file}: a rule starts with front matter between --- lines`,
|
|
210
208
|
});
|
|
211
209
|
}
|
|
212
|
-
const
|
|
210
|
+
const fields = parseFrontMatter(match[1]);
|
|
211
|
+
if (Object.hasOwn(fields, "reads")) {
|
|
212
|
+
return yield* ConfigUnavailable.make({
|
|
213
|
+
message: `${file}: move reads from the rule's front matter to the top level of defineConfig({...}) in your config file.`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const front = yield* Schema.decodeUnknownEffect(FrontMatter)(fields).pipe(
|
|
213
217
|
Effect.mapError((problem) =>
|
|
214
218
|
ConfigUnavailable.make({ message: `${file}: ${problem.message}` }),
|
|
215
219
|
),
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare's Clef on Workers AI. Clef follows System One, so adhere's
|
|
3
|
+
* questions go as Jev's provider sends them, but for three differences: at
|
|
4
|
+
* most 64 questions to a request, ids of letters, digits, `_`, `.` and `-`
|
|
5
|
+
* where adhere's hold `/` and `:`, and no `choice` of one option. So the
|
|
6
|
+
* questions go in chunks of 64, each under its place in its chunk, and a
|
|
7
|
+
* choice of one option is answered by it, unasked.
|
|
8
|
+
*
|
|
9
|
+
* Clef cuts a state too long for it short rather than refuse it, and says so
|
|
10
|
+
* only by the tokens it read: on 2026-10-10, clef read at most 64,000 and
|
|
11
|
+
* clef-flash 24,000, well under the 65,536 their pages give. A request that
|
|
12
|
+
* reads that many, or that Workers AI refuses as too large with a 413, is
|
|
13
|
+
* over the model's context, and skips the file.
|
|
14
|
+
*/
|
|
15
|
+
import { decoded, failed, post } from "#providers/post.ts";
|
|
16
|
+
import { ContextOverflow, type Provider, withoutSingleChoices } from "#providers/provider.ts";
|
|
17
|
+
import { answersOf, requestOf, SystemOneAnswered } from "#providers/systemOne.ts";
|
|
18
|
+
import { Schema } from "effect";
|
|
19
|
+
|
|
20
|
+
const QUESTIONS_PER_REQUEST = 64;
|
|
21
|
+
const MOST_TOKENS = { clef: 64_000, "clef-flash": 24_000 } as const;
|
|
22
|
+
|
|
23
|
+
/** Workers AI's envelope around Clef's answers. */
|
|
24
|
+
const Ran = Schema.fromJsonString(Schema.Struct({ result: SystemOneAnswered }));
|
|
25
|
+
|
|
26
|
+
export interface CloudflareOptions {
|
|
27
|
+
/** The Cloudflare account that runs the model. */
|
|
28
|
+
readonly accountId: string;
|
|
29
|
+
/** A Cloudflare API token that can run Workers AI models. */
|
|
30
|
+
readonly apiToken: string;
|
|
31
|
+
/** The model to ask. Defaults to "clef". */
|
|
32
|
+
readonly model?: "clef" | "clef-flash";
|
|
33
|
+
/** What sends the requests. Defaults to `fetch`. */
|
|
34
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const cloudflare = ({
|
|
38
|
+
accountId,
|
|
39
|
+
apiToken,
|
|
40
|
+
model = "clef",
|
|
41
|
+
fetch,
|
|
42
|
+
}: CloudflareOptions): Provider => ({
|
|
43
|
+
model,
|
|
44
|
+
ask: async (state, questions, { signal }) => {
|
|
45
|
+
const { answers, asked } = withoutSingleChoices(questions);
|
|
46
|
+
const chunks = Array.from({ length: Math.ceil(asked.length / QUESTIONS_PER_REQUEST) }, (_, n) =>
|
|
47
|
+
asked.slice(n * QUESTIONS_PER_REQUEST, (n + 1) * QUESTIONS_PER_REQUEST),
|
|
48
|
+
);
|
|
49
|
+
const ran = await Promise.all(
|
|
50
|
+
chunks.map(async (chunk) => {
|
|
51
|
+
const { response, text } = await post(
|
|
52
|
+
"Cloudflare",
|
|
53
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/@cf/cloudflare/${model}`,
|
|
54
|
+
{ Authorization: `Bearer ${apiToken}` },
|
|
55
|
+
requestOf(
|
|
56
|
+
model,
|
|
57
|
+
state,
|
|
58
|
+
chunk.map((question, place) => ({ ...question, id: `q${place}` })),
|
|
59
|
+
),
|
|
60
|
+
signal,
|
|
61
|
+
fetch,
|
|
62
|
+
);
|
|
63
|
+
const overflow = () => new ContextOverflow(`The request is over ${model}'s context`);
|
|
64
|
+
if (response.status === 413) throw overflow();
|
|
65
|
+
if (!response.ok) throw failed("Cloudflare", response, text);
|
|
66
|
+
const { result } = decoded("Cloudflare", Ran, text);
|
|
67
|
+
if ((result.usage?.input_tokens ?? 0) >= MOST_TOKENS[model]) throw overflow();
|
|
68
|
+
return { chunk, result };
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
for (const { chunk, result } of ran) {
|
|
72
|
+
const answered = answersOf(result.answers);
|
|
73
|
+
chunk.forEach((question, place) => {
|
|
74
|
+
const answer = answered[`q${place}`];
|
|
75
|
+
if (answer !== undefined) answers[question.id] = answer;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
answers,
|
|
80
|
+
inputTokens: ran.reduce((sum, { result }) => sum + (result.usage?.input_tokens ?? 0), 0),
|
|
81
|
+
model,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeSafe's Jev, adhere's default provider: adhere's questions as System One
|
|
3
|
+
* requests to TypeSafe's API, as adhere built them. With no provider in the
|
|
4
|
+
* config, adhere asks this one with the key `adhere login` saved, or
|
|
5
|
+
* `TYPESAFE_API_KEY`; a config names it to ask with another key or model.
|
|
6
|
+
*/
|
|
7
|
+
import { decoded, failed, post } from "#providers/post.ts";
|
|
8
|
+
import { ContextOverflow, type Provider, RequestBlocked } from "#providers/provider.ts";
|
|
9
|
+
import { answersOf, requestOf, SystemOneAnswered } from "#providers/systemOne.ts";
|
|
10
|
+
import { Option, Schema } from "effect";
|
|
11
|
+
|
|
12
|
+
const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
|
|
13
|
+
|
|
14
|
+
const Answer = Schema.fromJsonString(SystemOneAnswered);
|
|
15
|
+
|
|
16
|
+
/** What Jev answers, with a 400, to a request over its context. */
|
|
17
|
+
const Overflowed = Schema.fromJsonString(
|
|
18
|
+
Schema.Struct({ detail: Schema.Struct({ error_type: Schema.Literal("max_tokens_exceeded") }) }),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export interface JevOptions {
|
|
22
|
+
/** A TypeSafe AI API key. Defaults to `TYPESAFE_API_KEY`, or the key `adhere login` saved. */
|
|
23
|
+
readonly apiKey?: string;
|
|
24
|
+
/** The model to ask. Defaults to "jev-latest". */
|
|
25
|
+
readonly model?: string;
|
|
26
|
+
/** What sends the requests. Defaults to `fetch`. */
|
|
27
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const jev = ({ apiKey, model = "jev-latest", fetch }: JevOptions = {}): Provider => ({
|
|
31
|
+
model,
|
|
32
|
+
...(apiKey === undefined ? { savedKey: "typesafe" as const } : {}),
|
|
33
|
+
ask: async (state, questions, { apiKey: saved, signal }) => {
|
|
34
|
+
const key = apiKey ?? saved;
|
|
35
|
+
if (key === undefined) throw new Error("No TypeSafe AI API key to ask Jev with");
|
|
36
|
+
const { response, text } = await post(
|
|
37
|
+
"Jev",
|
|
38
|
+
SYSTEM_ONE,
|
|
39
|
+
{ Authorization: `Bearer ${key}` },
|
|
40
|
+
requestOf(model, state, questions),
|
|
41
|
+
signal,
|
|
42
|
+
fetch,
|
|
43
|
+
);
|
|
44
|
+
if (response.ok) {
|
|
45
|
+
const answered = decoded("Jev", Answer, text);
|
|
46
|
+
return {
|
|
47
|
+
answers: answersOf(answered.answers),
|
|
48
|
+
inputTokens: answered.usage?.input_tokens,
|
|
49
|
+
model: answered.model,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Over Jev's context, a 400 that skips the file. The firewall in front of
|
|
53
|
+
// TypeSafe's API answers a 403 with an HTML page, where Jev answers JSON:
|
|
54
|
+
// a block, with the Ray ID TypeSafe can look it up by.
|
|
55
|
+
if (response.status === 400 && Option.isSome(Schema.decodeUnknownOption(Overflowed)(text))) {
|
|
56
|
+
throw new ContextOverflow("The request is over Jev's context");
|
|
57
|
+
}
|
|
58
|
+
const html =
|
|
59
|
+
(response.headers.get("content-type") ?? "").includes("text/html") ||
|
|
60
|
+
text.trimStart().startsWith("<");
|
|
61
|
+
if (response.status === 403 && html) {
|
|
62
|
+
throw new RequestBlocked(response.headers.get("cf-ray") ?? "none given");
|
|
63
|
+
}
|
|
64
|
+
throw failed("Jev", response, text);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI's Decisions API: each of adhere's questions as a decision question,
|
|
3
|
+
* a `noul` as a `predicate` and a `choice` as a `choice`. A decision
|
|
4
|
+
* question's instructions are one string, so they hold the question as
|
|
5
|
+
* System One asks it, in JSON: the question beside the fields it names, and
|
|
6
|
+
* what yes and no mean. On the providers study's four repos, gpt-6-luna
|
|
7
|
+
* ranked findings better and located them more often than with the same parts
|
|
8
|
+
* in prose. The state is the input, in JSON. A question OpenAI refuses goes
|
|
9
|
+
* unanswered.
|
|
10
|
+
*/
|
|
11
|
+
import { decoded, failed, post } from "#providers/post.ts";
|
|
12
|
+
import { type Provider, type Question, withoutSingleChoices } from "#providers/provider.ts";
|
|
13
|
+
import { systemOneQuestionOf } from "#providers/systemOne.ts";
|
|
14
|
+
import { Schema } from "effect";
|
|
15
|
+
|
|
16
|
+
const DECISIONS = "https://api.openai.com/v1/decisions";
|
|
17
|
+
|
|
18
|
+
/** A decision's answers, in question order: a predicate's probability, a choice's value, or a refusal, which has neither. */
|
|
19
|
+
const Decided = Schema.fromJsonString(
|
|
20
|
+
Schema.Struct({
|
|
21
|
+
answers: Schema.Array(
|
|
22
|
+
Schema.Struct({
|
|
23
|
+
name: Schema.NullOr(Schema.String),
|
|
24
|
+
probability: Schema.optionalKey(Schema.Finite),
|
|
25
|
+
choice: Schema.optionalKey(Schema.Union([Schema.String, Schema.Boolean])),
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
model: Schema.optionalKey(Schema.String),
|
|
29
|
+
usage: Schema.optionalKey(Schema.Struct({ input_tokens: Schema.Finite })),
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/** System One's question without its type, in JSON, or its instructions alone when it says nothing of what yes and no mean. */
|
|
34
|
+
const instructionsOf = (question: Question): string => {
|
|
35
|
+
const { type, ...asked } = systemOneQuestionOf(question);
|
|
36
|
+
if (type === "noul" && asked.criteria !== undefined) return JSON.stringify(asked);
|
|
37
|
+
return typeof asked.instructions === "string"
|
|
38
|
+
? asked.instructions
|
|
39
|
+
: JSON.stringify(asked.instructions);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const decisionOf = (question: Question) =>
|
|
43
|
+
question.type === "noul"
|
|
44
|
+
? { type: "predicate", name: question.id, instructions: instructionsOf(question) }
|
|
45
|
+
: {
|
|
46
|
+
type: "choice",
|
|
47
|
+
name: question.id,
|
|
48
|
+
instructions: instructionsOf(question),
|
|
49
|
+
choices: Object.entries(question.options).map(([value, description]) =>
|
|
50
|
+
description === null ? { value } : { value, description },
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface OpenAIOptions {
|
|
55
|
+
/** An OpenAI API key. Defaults to `OPENAI_API_KEY`, or the key `adhere login --openai` saved. */
|
|
56
|
+
readonly apiKey?: string;
|
|
57
|
+
/** The model to ask. Defaults to "gpt-6-luna", the only one the Decisions API takes. */
|
|
58
|
+
readonly model?: string;
|
|
59
|
+
/** What sends the requests. Defaults to `fetch`. */
|
|
60
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const openai = ({ apiKey, model = "gpt-6-luna", fetch }: OpenAIOptions = {}): Provider => ({
|
|
64
|
+
model,
|
|
65
|
+
...(apiKey === undefined ? { savedKey: "openai" as const } : {}),
|
|
66
|
+
ask: async (state, questions, { apiKey: saved, signal }) => {
|
|
67
|
+
const key = apiKey ?? saved;
|
|
68
|
+
if (key === undefined) throw new Error("No OpenAI API key to ask gpt-6-luna with");
|
|
69
|
+
// OpenAI takes no choice of one option.
|
|
70
|
+
const { answers, asked } = withoutSingleChoices(questions);
|
|
71
|
+
if (asked.length === 0) return { answers };
|
|
72
|
+
const { response, text } = await post(
|
|
73
|
+
"OpenAI",
|
|
74
|
+
DECISIONS,
|
|
75
|
+
{ Authorization: `Bearer ${key}` },
|
|
76
|
+
{ model, input: JSON.stringify(state), questions: asked.map(decisionOf) },
|
|
77
|
+
signal,
|
|
78
|
+
fetch,
|
|
79
|
+
);
|
|
80
|
+
if (!response.ok) throw failed("OpenAI", response, text);
|
|
81
|
+
const decided = decoded("OpenAI", Decided, text);
|
|
82
|
+
for (const { name, probability, choice } of decided.answers) {
|
|
83
|
+
if (name === null) continue;
|
|
84
|
+
if (probability !== undefined) answers[name] = probability;
|
|
85
|
+
else if (choice !== undefined) answers[name] = String(choice);
|
|
86
|
+
}
|
|
87
|
+
return { answers, inputTokens: decided.usage?.input_tokens, model: decided.model };
|
|
88
|
+
},
|
|
89
|
+
});
|