@drkmttr/adhere 0.14.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.
Files changed (37) hide show
  1. package/README.md +294 -146
  2. package/bin/adhere.js +19 -4
  3. package/package.json +34 -12
  4. package/presets/effect/config/tests-provide-values-directly/RULE.md +12 -1
  5. package/presets/effect/data/brand-meaningful-primitives/RULE.md +14 -2
  6. package/presets/effect/data/variants-are-tagged-unions/RULE.md +17 -4
  7. package/presets/effect/services/dependencies-through-layers/RULE.md +16 -3
  8. package/presets/effect/services/no-mutable-state/RULE.md +7 -1
  9. package/skills/adhere/SKILL.md +180 -31
  10. package/skills/adhere-setup/SKILL.md +142 -16
  11. package/src/cli.ts +56 -23
  12. package/src/config.ts +49 -16
  13. package/src/index.ts +14 -0
  14. package/src/markdown.ts +9 -5
  15. package/src/providers/cloudflare.ts +84 -0
  16. package/src/providers/jev.ts +66 -0
  17. package/src/providers/openai.ts +89 -0
  18. package/src/providers/post.ts +53 -0
  19. package/src/providers/provider.ts +131 -0
  20. package/src/providers/providers.test.ts +276 -0
  21. package/src/providers/systemOne.ts +95 -0
  22. package/src/services/AdhereConfig.ts +2 -4
  23. package/src/services/Credentials.ts +101 -51
  24. package/src/services/Jev.http.ts +165 -170
  25. package/src/services/Jev.provider.test.ts +207 -0
  26. package/src/services/Jev.reads.test.ts +439 -0
  27. package/src/services/Jev.ts +205 -113
  28. package/src/services/NativeLsp.ts +289 -0
  29. package/src/services/SourceReads.test.ts +1040 -0
  30. package/src/services/SourceReads.ts +437 -0
  31. package/src/services/SourceWalker.ts +26 -59
  32. package/src/workflows/audit.ts +440 -265
  33. package/src/workflows/format.ts +74 -17
  34. package/src/workspaces.test.ts +73 -0
  35. package/src/workspaces.ts +91 -0
  36. package/presets/effect/services/operations-have-no-requirements/RULE.md +0 -50
  37. package/presets/effect/services/test-layers-are-in-memory/RULE.md +0 -29
@@ -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
+ });
@@ -0,0 +1,53 @@
1
+ /** How each built-in provider sends a request and reads what comes back. */
2
+ import { RequestFailed } from "#providers/provider.ts";
3
+ import { Schema } from "effect";
4
+
5
+ const messageOf = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause));
6
+
7
+ /**
8
+ * POSTs `body` as JSON, once: adhere tries again. A request that gets no
9
+ * answer, or whose answer breaks off, throws a `RequestFailed` with status 0.
10
+ */
11
+ export const post = async (
12
+ name: string,
13
+ url: string,
14
+ headers: Readonly<Record<string, string>>,
15
+ body: unknown,
16
+ signal: AbortSignal,
17
+ fetch: typeof globalThis.fetch = globalThis.fetch,
18
+ ): Promise<{ readonly response: Response; readonly text: string }> => {
19
+ try {
20
+ const response = await fetch(url, {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/json", ...headers },
23
+ body: JSON.stringify(body),
24
+ signal,
25
+ });
26
+ return { response, text: await response.text() };
27
+ } catch (cause) {
28
+ throw new RequestFailed(`${name} did not answer: ${messageOf(cause)}`, 0);
29
+ }
30
+ };
31
+
32
+ /**
33
+ * A response that is not ok, as the `RequestFailed` to throw: its status, the
34
+ * Ray ID of the Cloudflare edge in front of the API when there is one, and
35
+ * its body on one line and cut short.
36
+ */
37
+ export const failed = (name: string, response: Response, text: string): RequestFailed => {
38
+ const ray = response.headers.get("cf-ray");
39
+ const detail = text.trim().replace(/\s+/g, " ").slice(0, 300);
40
+ return new RequestFailed(
41
+ `${name} answered HTTP ${response.status}${ray === null ? "" : `, Ray ID ${ray}`}${detail === "" ? "" : `: ${detail}`}`,
42
+ response.status,
43
+ );
44
+ };
45
+
46
+ /** An ok response's body, decoded by `schema`. One that does not decode throws, naming who sent it. */
47
+ export const decoded = <A>(name: string, schema: Schema.Codec<A, string>, text: string): A => {
48
+ try {
49
+ return Schema.decodeUnknownSync(schema)(text);
50
+ } catch (cause) {
51
+ throw new Error(`${name}'s answer did not decode: ${messageOf(cause)}`);
52
+ }
53
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * What adhere asks a decision model, and what it takes back. A provider puts
3
+ * adhere's questions to its model in whatever form the model takes; adhere
4
+ * builds the questions, splits them into requests, paces and retries them,
5
+ * and reads the answers. Jev's provider is the default.
6
+ */
7
+ import { Schema } from "effect";
8
+
9
+ /**
10
+ * What the questions ask about: the file's code by section under `code`,
11
+ * beside what adhere read for it; comparing rules, the rules under `rules`.
12
+ */
13
+ export type State = Readonly<Record<string, unknown>>;
14
+
15
+ /**
16
+ * One of adhere's questions, as adhere phrases it. `question` names what it
17
+ * asks about in backticks: keys of the state, such as `code`, and of
18
+ * `fields`, such as `rule`, `must`, `never`, or `matcher`, which hold the
19
+ * rule it asks about and its examples. A `noul` asks whether something holds,
20
+ * `yes` and `no` saying what each answer means when adhere says. A `choice`
21
+ * asks for the key of one of its `options`, each beside what it means when
22
+ * its key does not say; it can have a single option.
23
+ */
24
+ export type Question =
25
+ | {
26
+ readonly id: string;
27
+ readonly type: "noul";
28
+ readonly question: string;
29
+ readonly fields: Readonly<Record<string, unknown>>;
30
+ readonly yes?: string;
31
+ readonly no?: string;
32
+ }
33
+ | {
34
+ readonly id: string;
35
+ readonly type: "choice";
36
+ readonly question: string;
37
+ readonly fields: Readonly<Record<string, unknown>>;
38
+ readonly options: Readonly<Record<string, string | null>>;
39
+ };
40
+
41
+ /**
42
+ * A provider's answers by question id: a `noul`'s probability that it holds,
43
+ * from 0 to 1, or the key of a `choice`'s option. An id left out goes
44
+ * unanswered: its rule is asked again on the next run, or its finding not
45
+ * located. `inputTokens` and `model`, what the model read and which version
46
+ * answered, are for the record, such as the eval's costs.
47
+ */
48
+ export const Answered = Schema.Struct({
49
+ answers: Schema.Record(Schema.String, Schema.Union([Schema.Finite, Schema.String])),
50
+ inputTokens: Schema.optional(Schema.Finite),
51
+ model: Schema.optional(Schema.String),
52
+ });
53
+ export interface Answered extends Schema.Schema.Type<typeof Answered> {}
54
+
55
+ /** The keys `adhere login` saves: TypeSafe AI's, for Jev, and with `--openai`, OpenAI's. */
56
+ export const SAVED_KEYS = ["typesafe", "openai"] as const;
57
+ export type SavedKey = (typeof SAVED_KEYS)[number];
58
+
59
+ /**
60
+ * What adhere passes each call of `ask`: the key, when the provider names a
61
+ * `savedKey`, and the signal that aborts the call when adhere gives up on it.
62
+ */
63
+ export interface AskOptions {
64
+ readonly apiKey?: string;
65
+ readonly signal: AbortSignal;
66
+ }
67
+
68
+ /**
69
+ * Where adhere's questions go. `ask` answers one request's questions. Its
70
+ * rejection stops the run, as Jev being down does, but for three:
71
+ * `ContextOverflow` skips the file, `RequestBlocked` reports it blocked, and
72
+ * `RequestFailed` is tried again when its status says it may pass. adhere
73
+ * paces each call with `--rpm`, and gives up on one after 30 seconds. `model`
74
+ * names what it asks: adhere's cache keeps answers by it, so a new model is
75
+ * asked again. A provider that names a `savedKey` gets that key as `apiKey`:
76
+ * its environment variable when set, otherwise the one `adhere login` saved.
77
+ * adhere calls `ask` on the provider, so a class can implement it.
78
+ */
79
+ export interface Provider {
80
+ readonly model: string;
81
+ readonly savedKey?: SavedKey;
82
+ ask(state: State, questions: ReadonlyArray<Question>, options: AskOptions): Promise<Answered>;
83
+ }
84
+
85
+ /**
86
+ * Splits off the choices of a single option, which some APIs refuse to be
87
+ * asked: each is answered by its option, and the rest are left to ask.
88
+ */
89
+ export const withoutSingleChoices = (questions: ReadonlyArray<Question>) => {
90
+ const answers: Record<string, number | string> = {};
91
+ const asked = questions.filter((question) => {
92
+ const [only, ...others] = question.type === "choice" ? Object.keys(question.options) : [];
93
+ if (only === undefined || others.length > 0) return true;
94
+ answers[question.id] = only;
95
+ return false;
96
+ });
97
+ return { answers, asked };
98
+ };
99
+
100
+ /** Thrown from `ask` when the request is longer than the model takes: adhere skips the file. */
101
+ export class ContextOverflow extends Error {
102
+ readonly _tag = "ContextOverflow";
103
+ }
104
+
105
+ /**
106
+ * Thrown from `ask` when something in front of the model refused the request
107
+ * for what it holds: adhere reports the file blocked, with `reference` to look
108
+ * the block up by, and goes on.
109
+ */
110
+ export class RequestBlocked extends Error {
111
+ readonly _tag = "RequestBlocked";
112
+ constructor(readonly reference: string) {
113
+ super(`blocked, reference ${reference}`);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Thrown from `ask` when the request failed: `status` is the HTTP status it
119
+ * got, or 0 when it got no answer. adhere tries one with no answer, a 408, a
120
+ * 429, or a 5xx again, up to three times, after half a second, one, and two;
121
+ * any other stops the run with its message.
122
+ */
123
+ export class RequestFailed extends Error {
124
+ readonly _tag = "RequestFailed";
125
+ constructor(
126
+ message: string,
127
+ readonly status: number,
128
+ ) {
129
+ super(message);
130
+ }
131
+ }