@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
@@ -37,10 +37,23 @@ wherever this skill says `adhere`, run it the manager's way, as in
37
37
 
38
38
  With pnpm, at a workspace root, add `--ignore-workspace-root-check`.
39
39
 
40
- In a repo without a `package.json`, use the `adhere` on PATH (`adhere
41
- --version`), or **Ask** before installing the executable from the latest
42
- release (`adhere-Windows-x86_64.exe` on Windows), which needs no Node or
43
- package manager:
40
+ Default lint enables `workspacePackages`, `symbols`, and `references`, and
41
+ starts native TypeScript only during execution, after confirmation, when
42
+ pending judgments or cached findings needing location require context. It discovers each
43
+ file's configured project, including package projects under solution roots,
44
+ and falls back to inferred context for unconfigured or excluded files. No root
45
+ `tsconfig.json` or literal root file-list membership is required; do not block
46
+ setup on either. `.adhere/tsconfig.json` only types the config and rules.
47
+ Planning, `--limit 0`, version, help, skills, and validate do not start the
48
+ compiler.
49
+
50
+ In a repo without a `package.json`, inspect any `adhere` on PATH with
51
+ `adhere --version`, then **Ask** whether to use the npm/Bun package launcher
52
+ for the full default or a raw executable with deliberately reduced context.
53
+ The raw release (`adhere-Windows-x86_64.exe` on Windows) needs no Node or
54
+ package manager, but its lint requires explicit config `reads: []` or
55
+ `reads: ["workspacePackages"]`. Do not silently disable readers to make it
56
+ work. Only fetch it after the user chooses that alternative:
44
57
 
45
58
  ```sh
46
59
  curl -fsSL --create-dirs -o ~/.local/bin/adhere \
@@ -48,9 +61,9 @@ curl -fsSL --create-dirs -o ~/.local/bin/adhere \
48
61
  chmod +x ~/.local/bin/adhere
49
62
  ```
50
63
 
51
- Validate and lint need a TypeSafe AI API key: `TYPESAFE_API_KEY`, or one saved
52
- by `adhere login`, which the user runs; never ask for the key in chat or print
53
- it.
64
+ Validate and lint's paid requests need a TypeSafe AI API key: `TYPESAFE_API_KEY`,
65
+ or one saved by `adhere login`, which the user runs; never ask for the key in
66
+ chat or print it. Compiler-free planning with `--limit 0` needs no key.
54
67
 
55
68
  ## 2. Find candidates
56
69
 
@@ -124,7 +137,12 @@ the wording of any. Revise drafts the user wants changed and show them again.
124
137
  - Without a config, run `adhere init`. It also writes two example rules under
125
138
  `.adhere/rules/style/`; delete them unless the user chose them.
126
139
  - Copy each chosen draft to `.adhere/rules/<topic>/<slug>/RULE.md`.
127
- - Add chosen presets, or topics, to `presets` in `.adhere/config.ts`.
140
+ - Add chosen presets, or topics, to `presets` in `.adhere/config.ts`. Presets
141
+ use the audit-wide readers; they do not own or override `reads`.
142
+ - Leave `reads` omitted for the full default. Do not add `reads: []` to the
143
+ normal setup recommendation or as a workaround for missing compiler
144
+ resources. Only set an explicit replacement array if the user chooses to
145
+ disable or limit readers.
128
146
  - Install chosen shared rules: `adhere install org/repo/<id>` for each, or
129
147
  `adhere install org/repo` for all of them. They land in `.adhere/rules/org/repo/`.
130
148
  - Add `exclude` globs for generated or vendored code the default skips miss.
@@ -146,15 +164,115 @@ which to keep, and apply it. Run it again until it reports no contradictions.
146
164
  adhere lint --limit 0
147
165
  ```
148
166
 
149
- This shows the plan and judges nothing: files, rules, requests, and the
150
- estimated cost. Tell the user those numbers. On a large repo, offer to start
151
- with a subtree, such as `--filter 'src/**'`, or a `--limit`. **Ask** before
152
- sending, then run it:
167
+ This shows a compiler-free plan and judges nothing: overall files/rules/cache
168
+ check metrics, and files deferred for a later run. Planning reads/counts/hashes
169
+ own code and checks cached answers; `--limit 0` skips budgeting, native reads,
170
+ and HTTP execution. A positive limit counts files needing requests, not rule
171
+ checks. Only selected files' pending rules and cached-location work are budgeted;
172
+ deferred/waiting counts are files, which can later be found too large. Planning never calls
173
+ `SourceReads` or resolves native symbols/references. Explain the output/prompt
174
+ caveat: estimates omit native context and hook-added data, so actual tokens,
175
+ cost, and request splits may be higher. Source reload/hash work is bounded to
176
+ 8 files; plans retain compact metadata, not all source text. Files are reloaded
177
+ for execution; if normalized code or ignore
178
+ directives changed since planning, rerun rather than using the old plan.
179
+
180
+ Tell the user that compiler type/docs strings and outgoing declaration snippets add
181
+ source/documentation sharing and token cost by default, including complete
182
+ scopes from files outside the audit filter. Whole declaration bodies can use more
183
+ tokens than fixed-line excerpts; budget overflow skips the file instead of
184
+ capping bodies to seven lines or silently truncating them. Compiler-provided
185
+ documentation and JSDoc tags can remain even when ordinary code comments are
186
+ stripped; omitting `symbols` disables type/docs context and omitting
187
+ `references` disables snippets. The global workspace catalog is not sent: only
188
+ this file's matched literal imports appear under canonical package keys.
189
+ Offer an explicit reader subset or code-only opt-out if they want less context;
190
+ never apply one silently. Neither reader selection nor dependency/shared-context
191
+ changes invalidate cached judgments. If they want fresh answers under changed
192
+ context, **Ask** before clearing `.adhere/cache/`; do not clear it automatically.
193
+
194
+ On a large repo, offer a subtree such as `--filter 'src/**'`, or a file cap
195
+ such as `--limit 100`: the first 100 files needing requests in path order, with
196
+ all pending rules per selected file. Judging plus cached-unlocated work in one
197
+ file uses one slot; a cached-locate-only file also counts. Fully cached and
198
+ already-located files report without using slots. After the cap, both judge and
199
+ locate work wait. Known cheap-budget skips refill from later files, but native
200
+ or HTTP-size skips after selection consume a slot without runtime refill.
201
+
202
+ Explain that a file cap is not an HTTP-request or token/cost cap: each file can
203
+ produce judge, locate, split-batch, and private hook requests. **Ask** before
204
+ sending, then run the command below with only the user's chosen filter/file cap:
153
205
 
154
206
  ```sh
155
207
  adhere lint --yes
156
208
  ```
157
209
 
210
+ `SourceReads.prepare(paths)` registers eligible-path metadata only, with no
211
+ file I/O, compiler/SDK import, spawn, snapshot, or semantic query, even for a large list.
212
+ Only after confirmation do pending judgments or cached findings still needing
213
+ location trigger actual per-file native reads just before judge/locate. Native
214
+ transactions are serial through one permit; HTTP execution remains concurrent
215
+ at 8. Returned context is reused locally and full budgets are enforced before
216
+ HTTP, with oversized files skipped and no silent truncation. Shared state plus
217
+ one question must fit 32k tokens and state plus all questions must fit 64k per
218
+ request, with 1k reserved for overhead. The SDK may still parse a full selected
219
+ project, and additional monorepo programs may increase startup time and memory.
220
+
221
+ `symbols` remains `Record<string, string>`: compiler-rendered distinct type
222
+ blocks for same-name binding/narrowing contexts, with compiler documentation
223
+ and JSDoc tags once per canonical symbol per file. It does not promise literal
224
+ IDE labels or selected-call overload presentation. Symbol/type queries are
225
+ batched, and independent SDK requests are chunked to at most 8 within the single
226
+ active native document/snapshot; HTTP work remains concurrent at 8. `references` maps used names to canonical outgoing declarations
227
+ by pathname, selecting the smallest complete AST scope without extra surrounding
228
+ lines. Functions include full bodies; arrow/object initializer variables include
229
+ whole variable statements with `export const` and semicolons. Methods, getters,
230
+ and setters include their whole member, not the entire class; classes,
231
+ interfaces, and type aliases include the whole declaration. Repeated/overlapping
232
+ scopes merge and disjoint scopes join with `…`. This is syntactic scope selection,
233
+ not recursive helper-call expansion: helpers called by an imported function are
234
+ not automatically expanded. Scopes preserve indentation and the comment/directive
235
+ policy, including associated JSDoc when `includeComments` is true; compiler
236
+ docs/tags in `symbols` remain available regardless. Current-file, declaration-file, `node_modules`, and
237
+ outside-root exclusions are unchanged. There are no incoming callers, counts, structured locations,
238
+ unresolved lists, full-file collection, or completeness markers.
239
+
240
+ The first actual read lazily starts one native TypeScript LSP process with its
241
+ public SDK API pipe; both are pinned to exactly `7.1.0-dev.20261009.1` and must
242
+ match. This prerelease contains the tuple-reference serialization fix from
243
+ [microsoft/TypeScript#64080](https://github.com/microsoft/TypeScript/issues/64080). `NativeLsp` owns lifecycle only and makes
244
+ no `textDocument/hover` calls. Each read uses the passed as-written text after
245
+ normalized-code/ignore active-plan validation, opens only that document at
246
+ version 1, uses `textDocument/documentSymbol` as its readiness barrier, and calls
247
+ the public, parameterless `API.getCurrentLanguageServerSnapshot()`.
248
+ Success closes the document, disposes that snapshot under a guarded phase, and
249
+ clears AST caches; native failure closes the session under the watchdog.
250
+
251
+ Keep dependencies/configs stable: only current-read text is pinned, not every
252
+ registered file or an immutable global audit snapshot. Warm compiler project
253
+ graphs can still be large and slow to load; neither total memory nor payload
254
+ size is constant, and contexts may still exceed model budgets.
255
+
256
+ SDK/write phases and LSP requests have 30-second deadlines and reject on compiler
257
+ exit. Queued readers can be interrupted; the active owner is uninterruptible
258
+ for resource safety. A stalled phase is bounded and cleanup adds a 2-second
259
+ `SIGKILL` watchdog. These are phase/cleanup bounds, not a whole-audit timeout.
260
+
261
+ If the user explicitly chose a cache refresh, run from the audited working
262
+ directory, using the repo's package launcher as above:
263
+
264
+ ```sh
265
+ rm -rf .adhere/cache
266
+ adhere lint --limit 0
267
+ ```
268
+
269
+ Show the fresh counts/cache plan; `--limit 0` skips budgeting. For selected
270
+ execution work, explain that its estimate omits native context and may understate
271
+ cost/splits, then **Ask** before paid execution. Deleting the cache removes judgments and linter tallies; it can cause
272
+ new paid requests. Use this explicitly if the user wants to compare fresh
273
+ compiler type/docs and declaration payloads with older cached answers. A change in `B.ts` does not
274
+ otherwise invalidate cached judgments for an unchanged importing `A.ts`.
275
+
158
276
  Walk the user through the report:
159
277
 
160
278
  1. How to read a finding: the rule id, the description, its confidence and
@@ -165,7 +283,8 @@ Walk the user through the report:
165
283
  3. For each noisy rule, propose one change from the adhere skill's "Tuning a
166
284
  rule": narrow the wording, scope it, raise its threshold, make it a
167
285
  warning, or turn it off. **Ask**, apply what the user accepts, and run
168
- lint again. Only the edited rules are judged again.
286
+ lint again. With unchanged own code and model, only the edited rules are
287
+ judged again; dependency/context changes alone are not tracked.
169
288
 
170
289
  CI fails on any error finding, so lint must exit 0 before step 8. **Ask** how
171
290
  to get there: fix the real findings now with the adhere-fix skill
@@ -212,9 +331,16 @@ jobs:
212
331
 
213
332
  Pin the versions the repo already uses: bun's `bun-version`, Node's
214
333
  `node-version` from `.nvmrc` or `engines`, and pnpm's from `packageManager`,
215
- which `pnpm/action-setup` reads. In a repo without a `package.json`, skip the
216
- setup and install, and fetch the executable at the version the user ran
217
- locally:
334
+ which `pnpm/action-setup` reads. Keep optional dependencies enabled so adhere's
335
+ platform package and its compiler dependency are installed. Existing project
336
+ configs and dependencies supply configured context when available; files with
337
+ no configured project or excluded from one use inferred context. Do not require
338
+ or generate a root `tsconfig.json` merely to make default lint run. In a repo
339
+ without a `package.json`, use the package launcher for the full default.
340
+ Only skip setup/install and fetch the raw executable if the user explicitly
341
+ chose and committed `reads: []` or `reads: ["workspacePackages"]` locally.
342
+ That is a reduced-context alternative, not an equivalent default installation.
343
+ Fetch the executable at the version the user ran:
218
344
 
219
345
  ```yaml
220
346
  - run: |
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 { Credentials, CredentialsLive, CredentialsUnavailable } from "#services/Credentials.ts";
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
- (checks) => checks >= 0,
114
- (checks) => `--limit is a number of checks, 0 or more, not ${checks}.`,
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
- "Judge at most this many checks. The rest wait for a later run, which picks up where this one stopped, since judgments are cached. --limit 0 shows the plan and judges nothing.",
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([FetchHttpClient.layer, CredentialsLive])),
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 (plan.requests > 0 && interactive && !input.yes) {
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
- JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
295
- AuditCacheLive,
296
- ).pipe(Layer.provideMerge(AdhereConfigLive(flags)), Layer.provideMerge(statusLayer));
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: "TypeSafe AI API key" })))
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
- /** `adhere login`: save the API key, so a run needs no TYPESAFE_API_KEY. */
465
- export const loginCommand = Command.make("login", {}, () =>
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 API key to ${yield* credentials.file}.`);
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 the saved API key. */
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 API key saved in ${file}.` : `No API key is saved in ${file}.`,
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
- /** The state a request to Jev carries: the file's code as numbered sections, which every question reads. */
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 a rule can have Jev read beside the code, each by the name of the key
37
- * the state carries it under, which the rule's description can name in
38
- * backticks. `workspacePackages` is the workspace's packages: each one's name
39
- * with its directory, so a rule can tell an import of the repository's own
40
- * package from an installed one's.
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, when a rule's `reads`
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, Reads, type Rule } from "#config.ts";
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
- * So is `reads`, of the names of what Jev reads for the rule beside the code,
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 front = yield* Schema.decodeUnknownEffect(FrontMatter)(parseFrontMatter(match[1])).pipe(
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
  ),