@drkmttr/adhere 0.9.4 → 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.
- package/README.md +257 -65
- package/package.json +6 -6
- package/presets/alchemy/apis/match-remote-errors-by-tag.md +6 -2
- package/presets/alchemy/apis/schemas-at-trust-boundaries.md +6 -2
- package/presets/alchemy/data/commit-migrations-then-deploy.md +6 -2
- package/presets/alchemy/data/hyperdrive-direct-origin.md +6 -2
- package/presets/alchemy/data/migrations-use-direct-url.md +6 -2
- package/presets/alchemy/data/never-disable-tls-verification.md +6 -2
- package/presets/alchemy/data/parameterized-sql.md +6 -2
- package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
- package/presets/alchemy/data/single-migration-owner.md +6 -2
- package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
- package/presets/alchemy/durable/io-inside-steps.md +6 -2
- package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
- package/presets/alchemy/durable/stable-step-names.md +6 -2
- package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
- package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
- package/presets/alchemy/providers/idempotent-delete.md +8 -3
- package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
- package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
- package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
- package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
- package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
- package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
- package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
- package/presets/alchemy/resources/stable-logical-ids.md +6 -2
- package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
- package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
- package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
- package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
- package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
- package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
- package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
- package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +6 -2
- package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
- package/presets/alchemy/secrets/redacted-until-use.md +6 -2
- package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
- package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
- package/presets/alchemy/security/github-secret-not-variable.md +6 -2
- package/presets/alchemy/security/identity-from-verified-session.md +6 -2
- package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
- package/presets/alchemy/security/least-privilege-bindings.md +6 -2
- package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
- package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
- package/presets/effect/basics/external-calls-are-resilient.md +6 -2
- package/presets/effect/basics/gen-for-sequencing.md +6 -2
- package/presets/effect/basics/instrument-with-pipe.md +6 -2
- package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
- package/presets/effect/config/secrets-are-redacted.md +6 -2
- package/presets/effect/config/tests-provide-values-directly.md +7 -2
- package/presets/effect/config/validate-with-schema.md +6 -2
- package/presets/effect/data/brand-meaningful-primitives.md +6 -2
- package/presets/effect/data/variants-are-tagged-unions.md +6 -2
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
- package/presets/effect/errors/defects-for-bugs.md +6 -2
- package/presets/effect/errors/wrap-external-errors.md +6 -2
- package/presets/effect/services/dependencies-through-layers.md +6 -2
- package/presets/effect/services/fresh-layer-per-test.md +7 -2
- package/presets/effect/services/memoize-parameterized-layers.md +6 -2
- package/presets/effect/services/no-mutable-state.md +6 -2
- package/presets/effect/services/operations-have-no-requirements.md +6 -2
- package/presets/effect/services/test-layers-are-in-memory.md +7 -2
- package/presets/effect/testing/test-clock-for-time.md +7 -2
- package/skills/adhere/SKILL.md +40 -12
- package/skills/adhere-fix/SKILL.md +174 -0
- package/src/cli.ts +51 -14
- package/src/comments.ts +36 -0
- package/src/config.ts +98 -14
- package/src/contradictions.ts +13 -3
- package/src/excerpt.ts +11 -1
- package/src/index.ts +1 -1
- package/src/init.ts +119 -12
- package/src/main.ts +0 -1
- package/src/markdown.ts +77 -14
- package/src/mechanical.ts +2 -2
- package/src/models/Audit.ts +2 -0
- package/src/presets.ts +9 -3
- package/src/pricing.ts +14 -0
- package/src/rules.ts +43 -1
- package/src/services/AdhereConfig.ts +40 -14
- package/src/services/Jev.ts +17 -0
- package/src/services/SourceWalker.ts +32 -10
- package/src/suppress.ts +113 -0
- package/src/wording.ts +2 -2
- package/src/workflows/audit.ts +137 -27
- package/src/workflows/format.ts +62 -15
package/skills/adhere/SKILL.md
CHANGED
|
@@ -58,51 +58,76 @@ threshold: 0.8
|
|
|
58
58
|
|
|
59
59
|
Optional prose for readers on GitHub. adhere ignores it.
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
## Must
|
|
62
|
+
|
|
63
|
+
```ts
|
|
62
64
|
// Correct code lifted from this repo, trimmed to the pattern.
|
|
63
65
|
```
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
## Never
|
|
68
|
+
|
|
69
|
+
```ts
|
|
66
70
|
// Optional: the incorrect form this rule catches, as it appeared in the repo.
|
|
67
71
|
```
|
|
68
72
|
````
|
|
69
73
|
|
|
70
74
|
Rules for the rule:
|
|
71
75
|
|
|
72
|
-
- `description` is one sentence, specific, in the same words as the
|
|
76
|
+
- `description` is one sentence, specific, in the same words as the headings:
|
|
73
77
|
what code must be, and what it must never be. Jev reads the description
|
|
74
78
|
with the code, and "must" in both is one demand; vague words ("properly",
|
|
75
79
|
"correctly") give it nothing to judge.
|
|
80
|
+
- A heading names the code under it only when it is the word alone: `## Never`,
|
|
81
|
+
not `## Never do this`. The next heading at its level or higher ends it.
|
|
76
82
|
- The `must` block is real code from the repo, not invented, and only correct
|
|
77
83
|
code: Jev compares files to it, so incorrect code there teaches the wrong
|
|
78
|
-
thing. Incorrect code goes
|
|
79
|
-
|
|
84
|
+
thing. Incorrect code goes under `## Never`, which Jev reads as what a
|
|
85
|
+
violation looks like. Add one whenever violations have a recognizable
|
|
80
86
|
shape, ideally one found in the repo's history: over the effect preset, a
|
|
81
87
|
`never` block raised how well Jev told violations from compliant code under
|
|
82
88
|
every wording tried. A rule with no single correct form can be only a
|
|
83
89
|
`never` block.
|
|
84
90
|
- A convention that is a guideline rather than a requirement says "should" in
|
|
85
|
-
its description and
|
|
86
|
-
or the other; it cannot mix the two.
|
|
91
|
+
its description and puts its code under `## Should` and `## Should not`. A
|
|
92
|
+
rule is one or the other; it cannot mix the two.
|
|
87
93
|
- One pattern per file. Two patterns in one `must` block blur the probability.
|
|
94
|
+
- Jev reads code without its comments, except comments that say `@adhere`. A
|
|
95
|
+
rule about comments, such as doc comments on exports, needs
|
|
96
|
+
`includeComments: true` in the config.
|
|
97
|
+
- Rules skip tests: `.test.ts` and `.spec.ts` files, and files under `test/`,
|
|
98
|
+
`tests/`, `__tests__/`, or `fixtures/`. A rule about tests says `tests: only`
|
|
99
|
+
in its front matter, and one that holds in tests as well `tests: include`.
|
|
100
|
+
- A nit, or a rule that tends to flag code wrongly, says `level: warning` in
|
|
101
|
+
its front matter: its findings show in amber and do not fail CI.
|
|
88
102
|
- `threshold` is optional. Start without it; set it in step 5 if needed.
|
|
89
103
|
|
|
90
104
|
## 4. Configure
|
|
91
105
|
|
|
92
106
|
Put the config at `.adhere/config.ts`. `adhere.config.ts` and
|
|
93
107
|
`.adhere.config.ts` also load; keep one. A config is optional when `.adhere/`
|
|
94
|
-
exists or a preset is passed on the command line.
|
|
95
|
-
|
|
108
|
+
exists or a preset is passed on the command line. `adhere init` scaffolds it
|
|
109
|
+
with `defineConfig`, adds `@drkmttr/adhere` as a dev dependency, and writes
|
|
110
|
+
`.adhere/tsconfig.json` so the editor resolves its types; the native binary
|
|
111
|
+
supplies the import either way:
|
|
96
112
|
|
|
97
113
|
```ts
|
|
98
|
-
import
|
|
114
|
+
import { defineConfig } from "@drkmttr/adhere";
|
|
99
115
|
|
|
100
|
-
export default {
|
|
116
|
+
export default defineConfig({
|
|
101
117
|
presets: ["effect"],
|
|
102
118
|
threshold: 0.75,
|
|
103
|
-
|
|
119
|
+
exclude: ["**/generated/**"],
|
|
120
|
+
});
|
|
104
121
|
```
|
|
105
122
|
|
|
123
|
+
To quiet a preset rule that does not suit the repo, override it rather than
|
|
124
|
+
copy it: `overrides: { "alchemy/providers/idempotent-delete": "warning" }`, or
|
|
125
|
+
`"off"`, or `{ threshold: 0.9 }`. The id is the one the report shows.
|
|
126
|
+
|
|
127
|
+
`exclude` lists globs, from the working directory, of files no rule judges.
|
|
128
|
+
Exclude generated and vendored code the skipped directories do not already
|
|
129
|
+
cover, since findings there are noise no one will fix.
|
|
130
|
+
|
|
106
131
|
Commit `.adhere/cache/` with the code, so the team and CI reuse its judgments
|
|
107
132
|
instead of paying for them again. Uncached files need a TypeSafe AI API
|
|
108
133
|
key: one saved by `adhere login`, or `TYPESAFE_API_KEY`, which takes
|
|
@@ -127,6 +152,9 @@ Then, per rule, read the findings and decide:
|
|
|
127
152
|
rule fires, remove the file. A rule that cannot fire is not a rule.
|
|
128
153
|
- Hits at 0.9 and above: read them first. They are usually real.
|
|
129
154
|
|
|
155
|
+
To verify and fix the findings themselves, one at a time, use the adhere-fix
|
|
156
|
+
skill, which `adhere skill fix` prints.
|
|
157
|
+
|
|
130
158
|
Threshold changes never re-judge; only edited rules do (per rule) and edited
|
|
131
159
|
files do (per file). Iterate on wording freely, it costs one request per file
|
|
132
160
|
per changed rule.
|
|
@@ -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 {
|
|
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 = (
|
|
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(
|
|
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
|
-
|
|
246
|
-
|
|
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 = (
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
380
|
-
|
|
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
|
|
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
|
|
package/src/comments.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
245
|
+
config: Pick<typeof AdhereConfig.Type, "presets">,
|
|
246
|
+
flags: Flags,
|
|
172
247
|
): ReadonlyArray<PresetName> => {
|
|
173
|
-
const named = [...new Set([...(
|
|
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
|
-
|
|
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,
|
|
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:
|
|
273
|
+
threshold: flags.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
|
|
199
274
|
rules: Object.assign({}, ...applied.map((preset) => preset.rules), config.rules),
|
|
200
|
-
...(
|
|
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
|
-
|
|
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({
|