@drkmttr/adhere 0.9.5 → 0.10.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.
Files changed (83) hide show
  1. package/README.md +245 -65
  2. package/package.json +6 -6
  3. package/presets/alchemy/apis/match-remote-errors-by-tag.md +6 -2
  4. package/presets/alchemy/apis/schemas-at-trust-boundaries.md +6 -2
  5. package/presets/alchemy/data/commit-migrations-then-deploy.md +6 -2
  6. package/presets/alchemy/data/hyperdrive-direct-origin.md +6 -2
  7. package/presets/alchemy/data/migrations-use-direct-url.md +6 -2
  8. package/presets/alchemy/data/never-disable-tls-verification.md +6 -2
  9. package/presets/alchemy/data/parameterized-sql.md +6 -2
  10. package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
  11. package/presets/alchemy/data/single-migration-owner.md +6 -2
  12. package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
  13. package/presets/alchemy/durable/io-inside-steps.md +6 -2
  14. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
  15. package/presets/alchemy/durable/stable-step-names.md +6 -2
  16. package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
  17. package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
  18. package/presets/alchemy/providers/idempotent-delete.md +8 -3
  19. package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
  20. package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
  21. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
  22. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
  23. package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
  24. package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
  25. package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
  26. package/presets/alchemy/resources/stable-logical-ids.md +6 -2
  27. package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
  28. package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
  29. package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
  30. package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
  31. package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
  32. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
  33. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
  34. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
  35. package/presets/alchemy/secrets/never-logged-returned-or-output.md +6 -2
  36. package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
  37. package/presets/alchemy/secrets/redacted-until-use.md +6 -2
  38. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
  39. package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
  40. package/presets/alchemy/security/github-secret-not-variable.md +6 -2
  41. package/presets/alchemy/security/identity-from-verified-session.md +6 -2
  42. package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
  43. package/presets/alchemy/security/least-privilege-bindings.md +6 -2
  44. package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
  45. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
  46. package/presets/effect/basics/external-calls-are-resilient.md +6 -2
  47. package/presets/effect/basics/gen-for-sequencing.md +6 -2
  48. package/presets/effect/basics/instrument-with-pipe.md +6 -2
  49. package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
  50. package/presets/effect/config/secrets-are-redacted.md +6 -2
  51. package/presets/effect/config/tests-provide-values-directly.md +7 -2
  52. package/presets/effect/config/validate-with-schema.md +6 -2
  53. package/presets/effect/data/brand-meaningful-primitives.md +6 -2
  54. package/presets/effect/data/variants-are-tagged-unions.md +6 -2
  55. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  56. package/presets/effect/errors/defects-for-bugs.md +6 -2
  57. package/presets/effect/errors/wrap-external-errors.md +6 -2
  58. package/presets/effect/services/dependencies-through-layers.md +6 -2
  59. package/presets/effect/services/fresh-layer-per-test.md +7 -2
  60. package/presets/effect/services/memoize-parameterized-layers.md +6 -2
  61. package/presets/effect/services/no-mutable-state.md +6 -2
  62. package/presets/effect/services/operations-have-no-requirements.md +6 -2
  63. package/presets/effect/services/test-layers-are-in-memory.md +7 -2
  64. package/presets/effect/testing/test-clock-for-time.md +7 -2
  65. package/skills/adhere/SKILL.md +40 -12
  66. package/skills/adhere-fix/SKILL.md +174 -0
  67. package/src/cli.ts +51 -14
  68. package/src/comments.ts +36 -0
  69. package/src/config.ts +98 -14
  70. package/src/contradictions.ts +10 -2
  71. package/src/excerpt.ts +11 -1
  72. package/src/index.ts +1 -1
  73. package/src/init.ts +119 -12
  74. package/src/main.ts +0 -1
  75. package/src/markdown.ts +77 -14
  76. package/src/models/Audit.ts +2 -0
  77. package/src/presets.ts +6 -3
  78. package/src/rules.ts +35 -2
  79. package/src/services/AdhereConfig.ts +36 -13
  80. package/src/services/SourceWalker.ts +32 -10
  81. package/src/suppress.ts +113 -0
  82. package/src/workflows/audit.ts +99 -17
  83. package/src/workflows/format.ts +20 -13
@@ -0,0 +1,174 @@
1
+ ---
2
+ name: adhere-fix
3
+ description: Verify and fix the findings `adhere lint` reports. Decide whether each finding is a real violation of its rule, fix the real ones, and report the false ones with the evidence, suppressing a finding with an `adhere-ignore` comment only when the user allows it. Use when adhere, or CI running it, reports errors or warnings, or when asked to fix adhere findings.
4
+ ---
5
+
6
+ # Verify and fix adhere findings
7
+
8
+ adhere is a linter for rules a normal linter cannot check. Jev, TypeSafe AI's
9
+ model, reads each file against each rule and answers with the probability that
10
+ the file breaks it; adhere reports each rule above a threshold, at the line Jev
11
+ points to. A finding is a judgment, not a proof. On one rule adhere's authors
12
+ checked against the live API, only 1 of its 20 findings was a real violation.
13
+ Verify every finding before you change code for it: fix the real ones, and
14
+ report the false ones with the evidence.
15
+
16
+ ## 1. Get the findings
17
+
18
+ Invoking this skill is the user's permission to run `adhere lint` as many
19
+ times as the work needs, and to pay for every request it sends to Jev. Never
20
+ ask before a run, never wait for a go-ahead, and never stop to report its cost:
21
+ the skill runs unattended, as in CI, where no one is there to answer. Pass
22
+ `--yes`, so adhere does not ask either.
23
+
24
+ ```sh
25
+ adhere lint --yes # judges what is not cached, then reports
26
+ adhere lint --yes --filter 'src/**' # only the files a glob matches
27
+ ```
28
+
29
+ Judgments are cached in `.adhere/cache/`, so a rerun judges only files and
30
+ rules that changed.
31
+
32
+ Each finding shows:
33
+
34
+ - the rule id, a preset's with its preset first, as in
35
+ `alchemy/providers/idempotent-delete`, Jev's probability, and the rule's
36
+ description;
37
+ - the line Jev points at, underlined, with the code around it;
38
+ - `hint:`, the code the rule wants, or `never:` for a rule that only shows code
39
+ to avoid.
40
+
41
+ `×` marks an error, which fails the run; `⚠` a warning, which does not: a nit,
42
+ or a rule that tends to flag code wrongly. Verify warnings the same way; they
43
+ are wrong more often.
44
+
45
+ ## 2. Read the rule
46
+
47
+ The report gives the rule's description and one example. Read the whole rule:
48
+ the code under `## Must` and `## Never`, and anything it puts out of scope.
49
+
50
+ - A project rule is `.adhere/<id>.md`, or under the directory the config's
51
+ `rules` names.
52
+ - A preset rule is `presets/<preset>/<id>.md` in the adhere package, in
53
+ `node_modules/@drkmttr/adhere/presets/`, or at
54
+ https://github.com/darkmatter/adhere/tree/main/presets.
55
+
56
+ ## 3. Verify each finding
57
+
58
+ Read the code as a reviewer would, not only the excerpt:
59
+
60
+ - **The whole unit.** Read the function, handler, or class the line is in,
61
+ and what calls it.
62
+ - **What it calls.** Follow helpers into other files. A helper can already do
63
+ what the rule asks, such as a delete that looks unguarded calling a client
64
+ that treats "already gone" as success.
65
+ - **The rule's scope.** Code the rule is not about, such as a runtime binding
66
+ under a rule about providers' delete handlers, is a false finding.
67
+ - **Comments are claims.** A comment saying the code is fine is not evidence.
68
+ In adhere's study of one rule, 8 of 35 comments saying a delete succeeds on a
69
+ missing resource were wrong. Check the claim before you rely on it. Jev does
70
+ not see comments, except those that say `@adhere`, so it judged the code
71
+ without them.
72
+ - **Behavior outside the file.** When the rule turns on how an API or a library
73
+ behaves, find out: its source, its tests, its documentation. Typings help but
74
+ can be wrong. A live check can settle it, such as a call with an identifier
75
+ no resource has, but only when the user asked for live checks, and never
76
+ against a real resource. Do not ask for one: without it, the finding is
77
+ unsure.
78
+
79
+ Then decide:
80
+
81
+ - **real**: the code breaks the rule as it is written, in its scope;
82
+ - **false**: it does not, and you can say how you know;
83
+ - **unsure**: you could not tell. Do not guess; leave it for the user.
84
+
85
+ ## 4. Fix a real finding
86
+
87
+ - Change the code the way the rule's `must` code shows, in the style of the
88
+ code around it, and only as much as the fix needs.
89
+ - Run the repo's tests and type check.
90
+ - Confirm with `adhere lint --yes --filter '<file>'`, which judges only the
91
+ changed file again. If the finding stays, reread the rule: the fix may not
92
+ be what it asks for.
93
+ - Try at most three fixes for one finding. If it stays after the third, undo
94
+ your changes for it, so no half-done fix is left, and leave it for the user.
95
+
96
+ ## 5. Suppress a finding, only when allowed
97
+
98
+ By default, never add an `adhere-ignore` comment or an `@adhere` note, and
99
+ never change the ones already there. Leave the finding, and say why in the
100
+ report. Suppressing is allowed only when the user allows it: their request
101
+ says so, or `ADHERE_ALLOW_SUPPRESSIONS=1` is set in your environment, which
102
+ `printenv ADHERE_ALLOW_SUPPRESSIONS` shows.
103
+
104
+ When it is allowed, suppress:
105
+
106
+ - a false finding;
107
+ - a real finding that three fixes did not clear, or whose fix would break
108
+ something, such as behavior, a public API, or loading the code on a
109
+ platform. The reason names what the fix would break.
110
+
111
+ Above the statement the finding is in, add a comment naming the rule as the
112
+ report does, then `--` and the evidence:
113
+
114
+ ```ts
115
+ // adhere-ignore alchemy/providers/idempotent-delete -- DeleteActivity succeeds on a missing activity; probed 2026-09-25
116
+ delete: Effect.fn(function* ({ output }) {
117
+ yield* sfn.deleteActivity({ activityArn: output.activityArn });
118
+ }),
119
+ ```
120
+
121
+ - The comment covers the whole statement that starts on the next line of code,
122
+ wherever in it Jev points. At the end of a line of code, it covers the
123
+ statement that starts on that line.
124
+ - `// adhere-ignore-file <rule> -- <reason>` covers a whole file. Use it only
125
+ when the rule is wrong for all of the file, such as generated code.
126
+ - Name several rules with commas: `// adhere-ignore <rule>, <rule> -- <reason>`.
127
+ - The reason says what you checked and what you found, so whoever reads it can
128
+ check it again. "False positive" is not a reason.
129
+ - Never suppress a finding you are unsure of, or a real one you have not
130
+ tried to fix.
131
+ - Jev never reads these comments: adhere removes them before it sends a file.
132
+
133
+ When suppressing is allowed and the finding was false because of a fact the
134
+ file does not show, such as how an API behaves, also write the fact where Jev
135
+ will see it the next time it judges this code: in a comment that says
136
+ `@adhere`, on the code the fact is about, with how you know.
137
+
138
+ ```ts
139
+ /**
140
+ * Deletes the activity. @adhere DeleteActivity succeeds on a missing
141
+ * activity, so no not-found error needs catching; probed 2026-09-25.
142
+ */
143
+ ```
144
+
145
+ A note informs Jev and suppresses nothing: Jev still judges the code, so a
146
+ real violation added later is still found. Write one only for a fact you
147
+ checked. A wrong note hides real violations the way wrong comments did.
148
+
149
+ ## 6. When a rule is wrong more often than right
150
+
151
+ If most of one rule's findings are false, suppressing them one by one hides
152
+ the problem. Stop suppressing that rule's findings, carry on with the other
153
+ rules, and in the report give the user the counts and suggest one of:
154
+
155
+ - in the config, `overrides: { "<rule id>": "warning" }`, or `"off"`, or
156
+ `{ threshold: 0.9 }`;
157
+ - for a project rule, rewording it to say what code it is about and what is
158
+ out of scope, following the rule writing tips:
159
+ https://github.com/darkmatter/adhere#rule-writing-tips.
160
+
161
+ ## 7. Report
162
+
163
+ Tell the user, for each finding: the rule, the file and line, the verdict, and
164
+ what you did: fixed it, suppressed it with the reason, or left it for them.
165
+ Give the evidence for each false one. Name the rules that were mostly wrong,
166
+ and every finding you were unsure of.
167
+
168
+ If you left findings because suppressing was not allowed, end the report with
169
+ this message, filled in:
170
+
171
+ > Left N findings that suppressing would settle: `<rule>` at `<file>:<line>`,
172
+ > … To let the agent suppress findings it shows are false, or that three fixes
173
+ > did not clear, with the evidence in each comment, set
174
+ > `ADHERE_ALLOW_SUPPRESSIONS=1` in the agent's environment and run it again.
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { Status } from "#services/Status.ts";
7
7
  import {
8
8
  type AuditPlan,
9
9
  executeAudit,
10
+ failing,
10
11
  type FileDone,
11
12
  planAudit,
12
13
  pruneCache,
@@ -19,9 +20,9 @@ import {
19
20
  progressLine,
20
21
  sendQuestion,
21
22
  } from "#workflows/format.ts";
22
- import type { Overrides } from "#config.ts";
23
+ import type { Flags } from "#config.ts";
23
24
  import { findContradictions, formatContradictions } from "#contradictions.ts";
24
- import { initProject } from "#init.ts";
25
+ import { type Install, initProject } from "#init.ts";
25
26
  import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
26
27
  import { type PresetName, presetNames } from "#presets.ts";
27
28
  import { globalRuleSet } from "#rules.ts";
@@ -41,13 +42,14 @@ import {
41
42
  Stdio,
42
43
  Stream,
43
44
  } from "effect";
44
- import { Command, Flag, Prompt } from "effect/unstable/cli";
45
+ import { Argument, Command, Flag, Prompt } from "effect/unstable/cli";
45
46
  import { FetchHttpClient } from "effect/unstable/http";
46
47
  // A Bun text import (https://bun.sh/docs/bundler/loaders#text): the file's
47
48
  // contents become a string at bundle time, so the compiled binary carries the
48
49
  // skill without an --asset flag. `skills/` sits beside `src/`, outside the `#`
49
50
  // import map, hence the relative path.
50
51
  import skill from "../skills/adhere/SKILL.md" with { type: "text" };
52
+ import fixSkill from "../skills/adhere-fix/SKILL.md" with { type: "text" };
51
53
 
52
54
  class FindingsReported extends Schema.TaggedError<FindingsReported>()("FindingsReported", {
53
55
  count: Schema.Finite,
@@ -124,6 +126,11 @@ const filter = Flag.string("filter").pipe(
124
126
  ),
125
127
  );
126
128
 
129
+ const denyWarnings = Flag.boolean("deny-warnings").pipe(
130
+ Flag.withDefault(false),
131
+ Flag.withDescription("Fail the run on warnings as well as errors."),
132
+ );
133
+
127
134
  const yes = Flag.boolean("yes").pipe(
128
135
  Flag.withAlias("y"),
129
136
  Flag.withDefault(false),
@@ -202,17 +209,17 @@ const force = Flag.boolean("force").pipe(
202
209
  Flag.withDescription("Overwrite existing scaffold files."),
203
210
  );
204
211
 
205
- export const auditLayer = (overrides: Overrides, filter: ReadonlyArray<string> = []) =>
212
+ export const auditLayer = (flags: Flags, filter: ReadonlyArray<string> = []) =>
206
213
  Layer.mergeAll(
207
214
  SourceWalkerLive(filter),
208
215
  AuditCacheLive,
209
216
  JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
210
- ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)), Layer.provideMerge(statusLayer));
217
+ ).pipe(Layer.provideMerge(AdhereConfigLive(flags)), Layer.provideMerge(statusLayer));
211
218
 
212
219
  /** `adhere lint`: the audit. */
213
220
  export const lintCommand = Command.make(
214
221
  "lint",
215
- { preset, threshold, yes, limit, rpm, filter },
222
+ { preset, threshold, yes, limit, rpm, filter, denyWarnings },
216
223
  (input) =>
217
224
  Effect.gen(function* () {
218
225
  const stdio = yield* Stdio.Stdio;
@@ -242,8 +249,10 @@ export const lintCommand = Command.make(
242
249
  const path = yield* Path.Path;
243
250
  // One write: separate Console.log calls have interleaved out of order here.
244
251
  yield* Console.log(render(result, { color, root: path.resolve() }).join("\n"));
245
- if (result.findings.length > 0) {
246
- yield* FindingsReported.make({ count: result.findings.length });
252
+ // Only errors fail the run, unless --deny-warnings: a warning is for a nit or a noisy rule.
253
+ const failed = failing(result.findings, input.denyWarnings);
254
+ if (failed.length > 0) {
255
+ yield* FindingsReported.make({ count: failed.length });
247
256
  }
248
257
  }),
249
258
  ).pipe(
@@ -263,11 +272,11 @@ export const lintCommand = Command.make(
263
272
  );
264
273
 
265
274
  /** Jev, with the key `lint` uses, and the config it reads the model and threshold from. */
266
- export const validateLayer = (overrides: Overrides) =>
275
+ export const validateLayer = (flags: Flags) =>
267
276
  Layer.merge(
268
277
  JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
269
278
  AuditCacheLive,
270
- ).pipe(Layer.provideMerge(AdhereConfigLive(overrides)), Layer.provideMerge(statusLayer));
279
+ ).pipe(Layer.provideMerge(AdhereConfigLive(flags)), Layer.provideMerge(statusLayer));
271
280
 
272
281
  /**
273
282
  * `adhere validate`: everything `lint` loads, then how each rule's wording
@@ -306,6 +315,17 @@ export const validateCommand = Command.make("validate", { preset }, () =>
306
315
  Command.provide((input) => validateLayer({ presets: input.preset })),
307
316
  );
308
317
 
318
+ const installLine = (install: Install): string => {
319
+ switch (install.status) {
320
+ case "installed":
321
+ return `installed: @drkmttr/adhere with ${install.packageManager}`;
322
+ case "listed":
323
+ return "installed: none, @drkmttr/adhere is already in package.json";
324
+ case "no-package-json":
325
+ return "installed: none, there is no package.json";
326
+ }
327
+ };
328
+
309
329
  /** `adhere init`: the scaffold. */
310
330
  export const initCommand = Command.make("init", { force }, (input) =>
311
331
  Effect.gen(function* () {
@@ -314,10 +334,15 @@ export const initCommand = Command.make("init", { force }, (input) =>
314
334
  const lines = [
315
335
  result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
316
336
  result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
337
+ installLine(result.install),
317
338
  ];
318
339
  yield* Console.log(lines.join("\n"));
319
340
  }),
320
- ).pipe(Command.withDescription("Scaffold .adhere/config.ts and example Markdown rules."));
341
+ ).pipe(
342
+ Command.withDescription(
343
+ "Scaffold .adhere/config.ts and example Markdown rules, and add @drkmttr/adhere to devDependencies with the project's package manager.",
344
+ ),
345
+ );
321
346
 
322
347
  /**
323
348
  * The key typed at a masked prompt, or piped in: `adhere login < key.txt`.
@@ -376,10 +401,22 @@ export const logoutCommand = Command.make("logout", {}, () =>
376
401
  Command.provide(CredentialsLive),
377
402
  );
378
403
 
379
- /** `adhere skill`: the agent skill for writing rules, as shipped in the binary. */
380
- export const skillCommand = Command.make("skill", {}, () => Console.log(skill.trimEnd())).pipe(
404
+ /** The agent skills the binary carries, by the name `adhere skill` takes. */
405
+ const skills = { rules: skill, fix: fixSkill } as const;
406
+
407
+ const skillName = Argument.choice("skill", ["rules", "fix"] as const).pipe(
408
+ Argument.withDefault("rules" as const),
409
+ Argument.withDescription(
410
+ "rules, the default, to gather a repo's conventions into rules and configure adhere; fix, to verify and fix the findings lint reports.",
411
+ ),
412
+ );
413
+
414
+ /** `adhere skill [rules|fix]`: an agent skill, as shipped in the binary. */
415
+ export const skillCommand = Command.make("skill", { name: skillName }, ({ name }) =>
416
+ Console.log(skills[name].trimEnd()),
417
+ ).pipe(
381
418
  Command.withDescription(
382
- "Print the agent skill that gathers a repo's conventions into rules and configures adhere. Pipe it into .agents/skills/adhere/SKILL.md or hand it to an agent.",
419
+ "Print an agent skill: by default the one that gathers a repo's conventions into rules and configures adhere, or with fix, the one that verifies and fixes lint's findings. Pipe it into .agents/skills/<name>/SKILL.md or hand it to an agent.",
383
420
  ),
384
421
  );
385
422
 
@@ -0,0 +1,36 @@
1
+ import { scan } from "#highlight.ts";
2
+
3
+ /** `@adhere` anywhere in a comment makes it a note for Jev. */
4
+ const NOTE = /@adhere\b/;
5
+
6
+ /**
7
+ * Whether a comment is a note for Jev: one that says `@adhere` anywhere in
8
+ * it, which stays when comments are taken out.
9
+ */
10
+ export const isNote = (comment: string): boolean => NOTE.test(comment);
11
+
12
+ /**
13
+ * A file's lines with its comments taken out, but for those `keep` keeps:
14
+ * each comment's text removed and its line breaks kept, so every line keeps
15
+ * its number, and a line with only the spaces before a comment left empty. A
16
+ * string, template literal, or regular expression that holds `//` or `/*`
17
+ * stays whole, as the highlighter reads it.
18
+ */
19
+ export const withoutComments = (
20
+ lines: ReadonlyArray<string>,
21
+ keep: (comment: string) => boolean = () => false,
22
+ ): ReadonlyArray<string> => {
23
+ const code = lines.join("\n");
24
+ // No comment can start without one of these, so a file without them is sent as it is.
25
+ if (!code.includes("//") && !code.includes("/*")) return lines;
26
+ const tokens = scan(code);
27
+ // What the highlighter could not read, if anything, stays as it was.
28
+ const read = tokens.reduce((length, token) => length + token.text.length, 0);
29
+ const kept =
30
+ tokens
31
+ .map(({ text, kind }) =>
32
+ kind === "comment" && !keep(text) ? text.replace(/[^\n]/g, "") : text,
33
+ )
34
+ .join("") + code.slice(read);
35
+ return kept.split("\n").map((line, index) => (line === lines[index] ? line : line.trimEnd()));
36
+ };
package/src/config.ts CHANGED
@@ -17,6 +17,17 @@ const RuleFields = Schema.Struct({
17
17
  should: Schema.optionalKey(Schema.String),
18
18
  shouldNot: Schema.optionalKey(Schema.String),
19
19
  threshold: Schema.optionalKey(Schema.Finite),
20
+ /**
21
+ * Test files, which a rule skips by default: `only` for a rule about tests,
22
+ * which judges nothing else, and `include` for one that holds in tests too.
23
+ */
24
+ tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
25
+ /**
26
+ * How a finding under the rule is reported: an `error`, the default, which
27
+ * fails the run, or a `warning`, for a nit or a rule that errs toward false
28
+ * findings, which does not.
29
+ */
30
+ level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
20
31
  });
21
32
 
22
33
  /** Before 0.7, a rule's examples were `reference` and `avoid`: read as `must` and `never`. */
@@ -51,6 +62,9 @@ export const Rule = Schema.Struct({
51
62
  );
52
63
  export interface Rule extends Schema.Schema.Type<typeof Rule> {}
53
64
 
65
+ /** How a finding is reported: an error fails the run, a warning does not. */
66
+ export type Level = NonNullable<Rule["level"]>;
67
+
54
68
  /** One of a rule's examples, under the word it is written with. */
55
69
  export interface Example {
56
70
  readonly word: "must" | "never" | "should" | "should not";
@@ -79,9 +93,29 @@ export type Rules = Readonly<Record<RuleId, Rule>>;
79
93
  /** Rules inline, or a directory of Markdown rule files (see `markdown.ts`). */
80
94
  export type RuleSource = Rules | string | URL;
81
95
 
96
+ /** A rule's setting in a config's `overrides`: `off`, a level, or a level and a threshold. */
97
+ const Override = Schema.Union([
98
+ Schema.Literals(["off", "error", "warning"]),
99
+ Schema.Struct({
100
+ level: Schema.optionalKey(Schema.Literals(["off", "error", "warning"])),
101
+ threshold: Schema.optionalKey(Schema.Finite),
102
+ }),
103
+ ]);
104
+ export type Override = typeof Override.Type;
105
+
82
106
  export const AdhereConfig = Schema.Struct({
107
+ /**
108
+ * The model id sent to typesafe. Defaults to "jev-latest"
109
+ */
83
110
  model: Schema.optionalKey(Schema.String),
111
+ /**
112
+ * The minimum confidence threshold for a positive match. Defaults to 0.8
113
+ */
84
114
  threshold: Schema.optionalKey(Schema.Finite),
115
+ /**
116
+ * The presets to use for rule matching. Defaults to an empty array. Current presets
117
+ * include "effect" and "alchemy". Subcategories can be expanded by appending `/<subcategory>`.
118
+ */
85
119
  presets: Schema.Array(Schema.Literals(presetNames)).pipe(
86
120
  Schema.withDecodingDefaultKey(Effect.succeed([])),
87
121
  ),
@@ -90,6 +124,24 @@ export const AdhereConfig = Schema.Struct({
90
124
  * Unset: `.adhere/` when that directory exists, otherwise no rules.
91
125
  */
92
126
  rules: Schema.optionalKey(Schema.Union([Schema.Record(Schema.String, Rule), Schema.String])),
127
+ /**
128
+ * Globs, relative to the working directory, of files no rule judges: what
129
+ * `--filter '!<glob>'` leaves out of one run, left out of every run.
130
+ */
131
+ exclude: Schema.optionalKey(Schema.Array(Schema.String)),
132
+ /**
133
+ * Settings for rules by the id a report names them with, a preset's with
134
+ * its preset first, as in `alchemy/providers/idempotent-delete`: `off`, a
135
+ * level, or `{ level, threshold }`. A preset's rule changes without its text
136
+ * being copied into `rules`.
137
+ */
138
+ overrides: Schema.optionalKey(Schema.Record(Schema.String, Override)),
139
+ /**
140
+ * Whether Jev reads every comment, for rules about comments. By default
141
+ * comments are taken out, since a comment changes nothing a file does, but
142
+ * for the notes that say `@adhere`.
143
+ */
144
+ includeComments: Schema.optionalKey(Schema.Boolean),
93
145
  });
94
146
 
95
147
  /** Where a repo keeps its rule files by default. The cache lives under it. */
@@ -120,17 +172,36 @@ export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
120
172
  "vendor",
121
173
  "e2e",
122
174
  ]);
123
- export interface AdhereConfig extends Schema.Schema.Type<typeof AdhereConfig> {}
175
+ /**
176
+ * Directories whose files are tests, matched as whole path segments below the
177
+ * working directory, as skipped directories are. Only a rule that says `tests`
178
+ * judges them, or a `.test.ts` or `.spec.ts` file anywhere.
179
+ */
180
+ export const TEST_DIRECTORIES: ReadonlySet<string> = new Set([
181
+ "test",
182
+ "tests",
183
+ "__tests__",
184
+ "fixtures",
185
+ ]);
124
186
 
125
- /** The shape a config file default-exports. */
187
+ /** The shape a config file default-exports: the schema's input, where `presets` is optional. */
126
188
  export type Config = typeof AdhereConfig.Encoded;
127
189
 
128
190
  export const defineConfig = (config: Config) => config;
129
191
 
130
192
  /** A built-in rule set. Same shape as a config, minus `presets`. */
131
193
  export interface Preset {
194
+ /**
195
+ * Override the model id used. Defaults to "jev-latest"
196
+ */
132
197
  readonly model?: string;
198
+ /**
199
+ * Override the threshold that determines a positive match. Defaults to 0.8
200
+ */
133
201
  readonly threshold?: number;
202
+ /**
203
+ * Per-rule overrides.
204
+ */
134
205
  readonly rules: RuleSource;
135
206
  }
136
207
 
@@ -150,15 +221,19 @@ export interface ResolvedConfig {
150
221
  readonly scopedRules?: RuleSet;
151
222
  /** Requests to Jev a minute, at most. Unset, requests go out as fast as they are made. */
152
223
  readonly rpm?: number;
224
+ /** The config's globs of files no rule judges. */
225
+ readonly exclude?: ReadonlyArray<string>;
226
+ /** Whether Jev reads every comment; unset, only the `@adhere` notes. */
227
+ readonly includeComments?: boolean;
153
228
  }
154
229
 
155
230
  /** Command-line values that apply on top of the config file. */
156
- export interface Overrides {
231
+ export interface Flags {
157
232
  readonly presets?: ReadonlyArray<PresetName>;
158
233
  /** Replaces the config's global threshold. Per-rule thresholds still win. */
159
- readonly threshold?: number;
234
+ readonly threshold?: number | undefined;
160
235
  /** Throttles requests to Jev to at most this many a minute. */
161
- readonly rpm?: number;
236
+ readonly rpm?: number | undefined;
162
237
  }
163
238
 
164
239
  /**
@@ -167,10 +242,10 @@ export interface Overrides {
167
242
  * the whole preset already holds its rules.
168
243
  */
169
244
  export const presetsOf = (
170
- config: Pick<AdhereConfig, "presets">,
171
- overrides: Overrides,
245
+ config: Pick<typeof AdhereConfig.Type, "presets">,
246
+ flags: Flags,
172
247
  ): ReadonlyArray<PresetName> => {
173
- const named = [...new Set([...(overrides.presets ?? []), ...config.presets])];
248
+ const named = [...new Set([...(flags.presets ?? []), ...config.presets])];
174
249
  return named.filter((name) => {
175
250
  const [whole] = name.split("/");
176
251
  return whole === name || !named.some((other) => other === whole);
@@ -183,11 +258,11 @@ export const presetsOf = (
183
258
  * rule with the same id.
184
259
  */
185
260
  export const resolveConfig = (
186
- config: Loaded<AdhereConfig>,
187
- overrides: Overrides = {},
261
+ config: Loaded<typeof AdhereConfig.Type>,
262
+ flags: Flags = {},
188
263
  registry: Readonly<Partial<Record<PresetName, Loaded<Preset>>>> = {},
189
264
  ): ResolvedConfig => {
190
- const applied = presetsOf(config, overrides).flatMap((name) => {
265
+ const applied = presetsOf(config, flags).flatMap((name) => {
191
266
  const preset = registry[name];
192
267
  return preset === undefined ? [] : [preset];
193
268
  });
@@ -195,9 +270,11 @@ export const resolveConfig = (
195
270
  applied.map((preset) => preset[key]).findLast((value) => value !== undefined);
196
271
  return {
197
272
  model: config.model ?? last("model") ?? DEFAULT_MODEL,
198
- threshold: overrides.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
273
+ threshold: flags.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
199
274
  rules: Object.assign({}, ...applied.map((preset) => preset.rules), config.rules),
200
- ...(overrides.rpm === undefined ? {} : { rpm: overrides.rpm }),
275
+ ...(flags.rpm === undefined ? {} : { rpm: flags.rpm }),
276
+ ...(config.exclude === undefined ? {} : { exclude: config.exclude }),
277
+ ...(config.includeComments === undefined ? {} : { includeComments: config.includeComments }),
201
278
  };
202
279
  };
203
280
 
@@ -206,7 +283,14 @@ export class ConfigUnavailable extends Schema.TaggedError<ConfigUnavailable>()(
206
283
  { message: Schema.String },
207
284
  ) {}
208
285
 
209
- export const decodeConfig = (value: unknown): Effect.Effect<AdhereConfig, ConfigUnavailable> =>
286
+ /**
287
+ * Decodes an in-memory value. @adhere The file-reading caller loadConfigFile
288
+ * in services/AdhereConfig.ts prefixes these errors with the config path;
289
+ * verified in its source and by CLI probes at all three config paths, 2026-09-26.
290
+ */
291
+ export const decodeConfig = (
292
+ value: unknown,
293
+ ): Effect.Effect<typeof AdhereConfig.Type, ConfigUnavailable> =>
210
294
  Schema.decodeUnknownEffect(AdhereConfig)(value).pipe(
211
295
  Effect.mapError((problem) =>
212
296
  ConfigUnavailable.make({
@@ -1,4 +1,5 @@
1
- import { type RuleEntry, shownId } from "#rules.ts";
1
+ import type { Rule } from "#config.ts";
2
+ import { judgesFile, type RuleEntry, shownId } from "#rules.ts";
2
3
  import { Jev, type Pair } from "#services/Jev.ts";
3
4
  import { Effect } from "effect";
4
5
 
@@ -17,6 +18,10 @@ const scopesOverlap = (a: string, b: string): boolean => {
17
18
  return first === second || first.startsWith(`${second}/`) || second.startsWith(`${first}/`);
18
19
  };
19
20
 
21
+ /** Whether two rules judge any of the same kind of file: a rule on tests only shares none with one that skips them. */
22
+ const judgeSameKind = (a: Rule, b: Rule): boolean =>
23
+ (judgesFile(a, true) && judgesFile(b, true)) || (judgesFile(a, false) && judgesFile(b, false));
24
+
20
25
  /**
21
26
  * Per rule, the rules that apply to some of the same files. A rule with the
22
27
  * same id is left out: a nested rule that shares an id shadows the other on
@@ -25,7 +30,10 @@ const scopesOverlap = (a: string, b: string): boolean => {
25
30
  const partnersOf = (entries: ReadonlyArray<RuleEntry>): ReadonlyArray<ReadonlyArray<number>> =>
26
31
  entries.map((first, index) =>
27
32
  entries.flatMap((second, other) =>
28
- other !== index && first.id !== second.id && scopesOverlap(first.scope, second.scope)
33
+ other !== index &&
34
+ first.id !== second.id &&
35
+ scopesOverlap(first.scope, second.scope) &&
36
+ judgeSameKind(first.rule, second.rule)
29
37
  ? [other]
30
38
  : [],
31
39
  ),
package/src/excerpt.ts CHANGED
@@ -16,7 +16,7 @@ const AROUND = 3;
16
16
  const WHOLE = 30;
17
17
 
18
18
  /** Lines of the code, first to last, counting from 1. */
19
- interface Range {
19
+ export interface Range {
20
20
  readonly first: number;
21
21
  readonly last: number;
22
22
  }
@@ -209,6 +209,16 @@ const statementsAround = (
209
209
  ];
210
210
  };
211
211
 
212
+ /**
213
+ * The statement that starts on `line`, such as a property, a function, or a
214
+ * call that opens there: the outermost statement around `line` whose first
215
+ * line it is. None when no statement starts on `line`.
216
+ */
217
+ export const statementFrom = (lines: ReadonlyArray<string>, line: number): Range | undefined => {
218
+ const { pieces } = piecesOf(lines.join("\n"));
219
+ return statementsAround(pieces, closesOf(pieces), line).find((range) => range.first === line);
220
+ };
221
+
212
222
  /**
213
223
  * The excerpt for a finding on `line`: the outermost statement around it
214
224
  * with at most `WHOLE` lines, such as the function it is in, and up to
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ export {
10
10
  type RuleSource,
11
11
  } from "#config.ts";
12
12
  export { findContradictions, formatContradictions, type Contradiction } from "#contradictions.ts";
13
- export { initProject, type InitOptions, type InitResult } from "#init.ts";
13
+ export { initProject, type InitOptions, type InitResult, type Install } from "#init.ts";
14
14
  export { parseRuleMarkdown } from "#markdown.ts";
15
15
  export { type PresetName, presetNames } from "#presets.ts";
16
16
  export {