@drkmttr/adhere 0.13.1 → 0.14.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 +1143 -862
- package/package.json +6 -6
- package/skills/adhere/SKILL.md +13 -7
- package/skills/adhere-setup/SKILL.md +21 -5
- package/src/config.ts +22 -8
- package/src/init.ts +5 -6
- package/src/markdown.ts +25 -11
- package/src/services/AdhereConfig.ts +1 -1
- package/src/services/Jev.http.ts +6 -4
- package/src/services/Jev.ts +9 -5
- package/src/workflows/audit.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules in .adhere/, judged by Jev.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
|
-
"@drkmttr/adhere-darwin-arm64": "0.
|
|
72
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
73
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
74
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
75
|
-
"@drkmttr/adhere-win32-x64": "0.
|
|
71
|
+
"@drkmttr/adhere-darwin-arm64": "0.14.0",
|
|
72
|
+
"@drkmttr/adhere-darwin-x64": "0.14.0",
|
|
73
|
+
"@drkmttr/adhere-linux-arm64": "0.14.0",
|
|
74
|
+
"@drkmttr/adhere-linux-x64": "0.14.0",
|
|
75
|
+
"@drkmttr/adhere-win32-x64": "0.14.0"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -53,7 +53,8 @@ config in `.adhere/` are not rules.
|
|
|
53
53
|
description: A port must be a branded, range-checked integer, never a bare number.
|
|
54
54
|
---
|
|
55
55
|
|
|
56
|
-
Prose here
|
|
56
|
+
Prose here is the rule's details: Jev reads it after the description. Say why
|
|
57
|
+
the rule holds, or where it does not apply.
|
|
57
58
|
|
|
58
59
|
## Must
|
|
59
60
|
|
|
@@ -88,6 +89,9 @@ How to word a rule, from adhere's evals:
|
|
|
88
89
|
- One pattern per rule. Two in one block blur the probability.
|
|
89
90
|
- A heading names its code only when it is the word alone: `## Never`, not
|
|
90
91
|
`## Never do this`. A fence tagged `ts never` names its code itself.
|
|
92
|
+
- Prose outside the code is sent to Jev with the description, so keep it about
|
|
93
|
+
the rule: why it holds, or where it does not apply. A note for the people who
|
|
94
|
+
maintain the rule, rather than for judging code, belongs elsewhere.
|
|
91
95
|
|
|
92
96
|
Front matter besides `description`, all optional:
|
|
93
97
|
|
|
@@ -102,12 +106,14 @@ Front matter besides `description`, all optional:
|
|
|
102
106
|
one line, scope a rule without widening its description: a finding stands
|
|
103
107
|
only when Jev says the code that breaks the rule is as every `appliesTo`
|
|
104
108
|
describes and as no `excludeIf` does.
|
|
105
|
-
- `
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
- `reads: ["workspacePackages"]`, a JSON array on one line, names what Jev
|
|
110
|
+
reads for the rule beside the code, each under that name. There is one so
|
|
111
|
+
far, `workspacePackages`: each of the repository's own packages, by name,
|
|
112
|
+
with its directory, read from the root `package.json`'s `workspaces` or
|
|
113
|
+
`pnpm-workspace.yaml`. Use it for a rule that turns on whether an import is
|
|
114
|
+
of the repository's own package or an installed one, and name
|
|
115
|
+
`workspacePackages` in the description. A name adhere does not have refuses
|
|
116
|
+
the run. The rule costs a request of its own per file, and a changed
|
|
111
117
|
workspace judges it again.
|
|
112
118
|
|
|
113
119
|
A rule's directory can hold a `RULE.ts` instead, which default-exports
|
|
@@ -38,8 +38,15 @@ wherever this skill says `adhere`, run it the manager's way, as in
|
|
|
38
38
|
With pnpm, at a workspace root, add `--ignore-workspace-root-check`.
|
|
39
39
|
|
|
40
40
|
In a repo without a `package.json`, use the `adhere` on PATH (`adhere
|
|
41
|
-
--version`), or **Ask** before installing
|
|
42
|
-
|
|
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:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
curl -fsSL --create-dirs -o ~/.local/bin/adhere \
|
|
47
|
+
https://github.com/darkmatter/adhere/releases/latest/download/adhere-$(uname -s)-$(uname -m)
|
|
48
|
+
chmod +x ~/.local/bin/adhere
|
|
49
|
+
```
|
|
43
50
|
|
|
44
51
|
Validate and lint need a TypeSafe AI API key: `TYPESAFE_API_KEY`, or one saved
|
|
45
52
|
by `adhere login`, which the user runs; never ask for the key in chat or print
|
|
@@ -205,9 +212,18 @@ jobs:
|
|
|
205
212
|
|
|
206
213
|
Pin the versions the repo already uses: bun's `bun-version`, Node's
|
|
207
214
|
`node-version` from `.nvmrc` or `engines`, and pnpm's from `packageManager`,
|
|
208
|
-
which `pnpm/action-setup` reads. In a repo without a `package.json`,
|
|
209
|
-
|
|
210
|
-
|
|
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:
|
|
218
|
+
|
|
219
|
+
```yaml
|
|
220
|
+
- run: |
|
|
221
|
+
curl -fsSL -o "$RUNNER_TEMP/adhere" https://github.com/darkmatter/adhere/releases/download/v0.14.0/adhere-$(uname -s)-$(uname -m)
|
|
222
|
+
chmod +x "$RUNNER_TEMP/adhere"
|
|
223
|
+
"$RUNNER_TEMP/adhere" lint --yes
|
|
224
|
+
env:
|
|
225
|
+
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
|
|
226
|
+
```
|
|
211
227
|
|
|
212
228
|
Add `--deny-warnings` if the user wants warnings to fail the job too. Match
|
|
213
229
|
the branch to the repo's default branch. For another CI system, write the
|
package/src/config.ts
CHANGED
|
@@ -32,6 +32,15 @@ export type AppendState = (
|
|
|
32
32
|
bun: BunApi,
|
|
33
33
|
) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
|
|
34
34
|
|
|
35
|
+
/**
|
|
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.
|
|
41
|
+
*/
|
|
42
|
+
export const Reads = Schema.Array(Schema.Literals(["workspacePackages"]));
|
|
43
|
+
|
|
35
44
|
/**
|
|
36
45
|
* A rule's examples, named in RFC 2119's words, which Jev reads as written. A
|
|
37
46
|
* requirement shows code that `must` be written and code that must `never`
|
|
@@ -40,6 +49,13 @@ export type AppendState = (
|
|
|
40
49
|
*/
|
|
41
50
|
const RuleFields = Schema.Struct({
|
|
42
51
|
description: Schema.String,
|
|
52
|
+
/**
|
|
53
|
+
* What the description leaves out, such as why the rule holds or where it
|
|
54
|
+
* does not: in a `RULE.md`, the prose around its code. Jev reads it after
|
|
55
|
+
* the description, as part of the rule; the report shows the description
|
|
56
|
+
* alone.
|
|
57
|
+
*/
|
|
58
|
+
details: Schema.optionalKey(Schema.String),
|
|
43
59
|
must: Schema.optionalKey(Schema.String),
|
|
44
60
|
never: Schema.optionalKey(Schema.String),
|
|
45
61
|
should: Schema.optionalKey(Schema.String),
|
|
@@ -68,13 +84,11 @@ const RuleFields = Schema.Struct({
|
|
|
68
84
|
*/
|
|
69
85
|
excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
70
86
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* The rule's questions go in requests of their own, and a change to the
|
|
75
|
-
* packages judges it again.
|
|
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.
|
|
76
90
|
*/
|
|
77
|
-
|
|
91
|
+
reads: Schema.optionalKey(Reads),
|
|
78
92
|
/**
|
|
79
93
|
* A hook on the state Jev reads for the rule, in a rule written in
|
|
80
94
|
* TypeScript (see `AppendState`). The rule's questions go in requests of
|
|
@@ -306,8 +320,8 @@ export interface ResolvedConfig {
|
|
|
306
320
|
readonly includeComments?: boolean;
|
|
307
321
|
/**
|
|
308
322
|
* The workspace's packages, each name with its directory from the working
|
|
309
|
-
* directory, read from its manifests on every run, when a rule
|
|
310
|
-
* them
|
|
323
|
+
* directory, read from its manifests on every run, when a rule's `reads`
|
|
324
|
+
* names them.
|
|
311
325
|
*/
|
|
312
326
|
readonly workspacePackages?: Readonly<Record<string, string>>;
|
|
313
327
|
}
|
package/src/init.ts
CHANGED
|
@@ -68,9 +68,8 @@ description: A file should be small and focused, with one primary responsibility
|
|
|
68
68
|
threshold: 0.8
|
|
69
69
|
---
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
Use this rule as a template for repository-specific conventions.
|
|
71
|
+
Why: a file that does one thing is easier to find, read, and change. A helper
|
|
72
|
+
that serves only that thing belongs in the same file.
|
|
74
73
|
|
|
75
74
|
## Should
|
|
76
75
|
|
|
@@ -93,6 +92,9 @@ description: A function must be named after the domain action it performs, never
|
|
|
93
92
|
threshold: 0.8
|
|
94
93
|
---
|
|
95
94
|
|
|
95
|
+
Why: a name like \`handle\` or \`process\` says nothing about what the function
|
|
96
|
+
does, so every caller has to open it to find out.
|
|
97
|
+
|
|
96
98
|
## Must
|
|
97
99
|
|
|
98
100
|
\`\`\`ts
|
|
@@ -104,9 +106,6 @@ export const loadCustomerProfile = (customerId: CustomerId) =>
|
|
|
104
106
|
|
|
105
107
|
## Never
|
|
106
108
|
|
|
107
|
-
The code under Never shows what a violation looks like. It is optional, and a
|
|
108
|
-
rule can have it without the code under Must.
|
|
109
|
-
|
|
110
109
|
\`\`\`ts
|
|
111
110
|
export const handle = (id: string) =>
|
|
112
111
|
Effect.gen(function* () {
|
package/src/markdown.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigUnavailable, type Example, type Rule } from "#config.ts";
|
|
1
|
+
import { ConfigUnavailable, type Example, Reads, type Rule } from "#config.ts";
|
|
2
2
|
import { Effect, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -8,7 +8,7 @@ import { Effect, Schema } from "effect";
|
|
|
8
8
|
* description: One sentence saying what code must be, or never be.
|
|
9
9
|
* threshold: 0.8
|
|
10
10
|
* ---
|
|
11
|
-
* Any prose,
|
|
11
|
+
* Any prose, which Jev reads after the description, as the rule's details.
|
|
12
12
|
*
|
|
13
13
|
* ## Must
|
|
14
14
|
*
|
|
@@ -34,6 +34,8 @@ 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
|
const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
|
|
39
41
|
|
|
@@ -44,7 +46,7 @@ const FrontMatter = Schema.Struct({
|
|
|
44
46
|
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
45
47
|
appliesTo: Schema.optionalKey(Matchers),
|
|
46
48
|
excludeIf: Schema.optionalKey(Matchers),
|
|
47
|
-
|
|
49
|
+
reads: Schema.optionalKey(Schema.fromJsonString(Reads)),
|
|
48
50
|
});
|
|
49
51
|
|
|
50
52
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
@@ -149,6 +151,24 @@ const fencesOf = (body: string): ReadonlyArray<Fence> => {
|
|
|
149
151
|
const nonEmpty = (code: string | undefined): string | undefined =>
|
|
150
152
|
code === undefined || code.length === 0 ? undefined : code;
|
|
151
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The body's prose: what is left outside its fences and the headings that
|
|
156
|
+
* name their code, which Jev reads after the description. A body without a
|
|
157
|
+
* fence is all code, so it has none.
|
|
158
|
+
*/
|
|
159
|
+
const detailsOf = (body: string): string | undefined =>
|
|
160
|
+
fencesOf(body).length === 0
|
|
161
|
+
? undefined
|
|
162
|
+
: nonEmpty(
|
|
163
|
+
body
|
|
164
|
+
.replace(FENCE, "")
|
|
165
|
+
.replace(HEADING, (line, _level, text: string | undefined) =>
|
|
166
|
+
headingWordOf(text ?? "") === undefined ? line : "",
|
|
167
|
+
)
|
|
168
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
169
|
+
.trim(),
|
|
170
|
+
);
|
|
171
|
+
|
|
152
172
|
type Code = Pick<Rule, "must" | "never" | "should" | "shouldNot">;
|
|
153
173
|
|
|
154
174
|
/**
|
|
@@ -207,12 +227,6 @@ export const parseRuleMarkdown = (
|
|
|
207
227
|
message: `${file}: the body needs code, in a fence under a Must, Never, Should, or Should not heading, or untagged`,
|
|
208
228
|
});
|
|
209
229
|
}
|
|
210
|
-
const
|
|
211
|
-
return {
|
|
212
|
-
...fields,
|
|
213
|
-
...(includeWorkspacePackages === undefined
|
|
214
|
-
? {}
|
|
215
|
-
: { includeWorkspacePackages: includeWorkspacePackages === "true" }),
|
|
216
|
-
...code,
|
|
217
|
-
};
|
|
230
|
+
const details = detailsOf(match[2]);
|
|
231
|
+
return { ...front, ...(details === undefined ? {} : { details }), ...code };
|
|
218
232
|
});
|
|
@@ -133,7 +133,7 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
133
133
|
const scopedRules = withOverrides(entries, overrides);
|
|
134
134
|
// The manifests are read on every run, so a changed workspace is seen, and only when a rule reads them.
|
|
135
135
|
const workspacePackages = scopedRules.some(
|
|
136
|
-
(entry) => entry.rule.
|
|
136
|
+
(entry) => entry.rule.reads?.includes("workspacePackages") === true,
|
|
137
137
|
)
|
|
138
138
|
? yield* workspacePackagesOf(cwd)
|
|
139
139
|
: undefined;
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -68,9 +68,9 @@ const refused = (message: string) => JevUnavailable.make({ message });
|
|
|
68
68
|
const bun = (globalThis as { readonly Bun?: unknown }).Bun as Parameters<AppendState>[2];
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* A body as it goes out: when its rule
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
* A body as it goes out: when its rule's `reads` names the workspace's
|
|
72
|
+
* packages, with them in its state, and when it has `appendState`, with what
|
|
73
|
+
* the hook returns spread over that. A hook that fails refuses the run.
|
|
74
74
|
*/
|
|
75
75
|
const hooked = (
|
|
76
76
|
sent: Body & { readonly state: JevState },
|
|
@@ -78,7 +78,9 @@ const hooked = (
|
|
|
78
78
|
file: JudgedFile,
|
|
79
79
|
workspacePackages: Readonly<Record<string, string>>,
|
|
80
80
|
): Effect.Effect<Body, JevUnavailable> => {
|
|
81
|
-
const body = Object.values(group).some(
|
|
81
|
+
const body = Object.values(group).some(
|
|
82
|
+
(rule) => rule.reads?.includes("workspacePackages") === true,
|
|
83
|
+
)
|
|
82
84
|
? { ...sent, state: { ...sent.state, workspacePackages } }
|
|
83
85
|
: sent;
|
|
84
86
|
const [hook] = Object.entries(group).flatMap(([id, rule]) =>
|
package/src/services/Jev.ts
CHANGED
|
@@ -160,11 +160,15 @@ const exampleFields = (rule: Rule): Record<string, string> => {
|
|
|
160
160
|
};
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
-
/** The rule as
|
|
164
|
-
const
|
|
163
|
+
/** The rule in words, as Jev reads it: its description, then its details when it has any. */
|
|
164
|
+
const ruleText = (rule: Rule): string =>
|
|
165
|
+
rule.details === undefined ? rule.description : `${rule.description}\n\n${rule.details}`;
|
|
166
|
+
|
|
167
|
+
/** The rule as its questions carry it: its text, and its examples under their words. */
|
|
168
|
+
const ruleFields = (rule: Rule) => ({ rule: ruleText(rule), ...exampleFields(rule) });
|
|
165
169
|
|
|
166
170
|
/** A rule's text as a comparison of rules reads it. */
|
|
167
|
-
const ruleState = (rule: Rule) => ({ description: rule
|
|
171
|
+
const ruleState = (rule: Rule) => ({ description: ruleText(rule), ...exampleFields(rule) });
|
|
168
172
|
|
|
169
173
|
/**
|
|
170
174
|
* The compared rules a question mentions, keyed by index: ids alone would
|
|
@@ -477,9 +481,9 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
|
|
|
477
481
|
return requests.map(({ questions }) => ({ ...body, questions }));
|
|
478
482
|
};
|
|
479
483
|
|
|
480
|
-
/** Whether a rule reads state of its own: what its `appendState` adds, or
|
|
484
|
+
/** Whether a rule reads state of its own: what its `appendState` adds, or what its `reads` names. */
|
|
481
485
|
const readsOwnState = (rule: Rule): boolean =>
|
|
482
|
-
rule.appendState !== undefined || rule.
|
|
486
|
+
rule.appendState !== undefined || (rule.reads ?? []).length > 0;
|
|
483
487
|
|
|
484
488
|
/**
|
|
485
489
|
* The rules as requests carry them: every rule that reads only the code in
|
package/src/workflows/audit.ts
CHANGED
|
@@ -125,7 +125,9 @@ const fingerprintOf = (
|
|
|
125
125
|
const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
|
|
126
126
|
const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
|
|
127
127
|
const packages =
|
|
128
|
-
rule.
|
|
128
|
+
rule.reads?.includes("workspacePackages") === true
|
|
129
|
+
? `\u0000${JSON.stringify(workspacePackages)}`
|
|
130
|
+
: "";
|
|
129
131
|
return sha256(
|
|
130
132
|
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${packages}`,
|
|
131
133
|
);
|