@drkmttr/adhere 0.3.0 → 0.4.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 +40 -18
- package/package.json +12 -6
- package/skills/adhere/SKILL.md +10 -3
- package/src/config.ts +30 -2
- package/src/init.ts +10 -0
- package/src/markdown.ts +42 -10
- package/src/rules.ts +14 -2
- package/src/services/Jev.ts +33 -9
- package/src/services/SourceWalker.ts +14 -17
- package/src/workflows/audit.ts +17 -2
- package/src/workflows/format.ts +9 -5
- package/.adhere/config.ts +0 -7
- package/.adhere/rules/refusals-name-the-file.md +0 -17
- package/.github/workflows/ci.yaml +0 -27
- package/.github/workflows/publish.yaml +0 -68
- package/.github/workflows/release.yaml +0 -71
- package/bun.lock +0 -773
- package/scripts/npm-packages.ts +0 -68
- package/test/audit.test.ts +0 -540
- package/test/cli.test.ts +0 -45
- package/tsconfig.json +0 -26
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# adhere
|
|
2
2
|
|
|
3
|
-
A linter for rules a normal linter cannot check. A rule is a
|
|
4
|
-
reference code
|
|
5
|
-
asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
|
|
6
|
-
file
|
|
3
|
+
A linter for rules a normal linter cannot check. A rule is a one-sentence
|
|
4
|
+
description plus correct reference code, code to avoid, or both. For each
|
|
5
|
+
source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
|
|
6
|
+
model) whether the file breaks each rule, gets a calibrated probability per
|
|
7
7
|
rule, and reports the ones above a threshold with the line Jev points at. The
|
|
8
8
|
report uses the same frame as `vp lint`.
|
|
9
9
|
|
|
@@ -54,7 +54,8 @@ Found 1 error.
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
The header is the rule id, Jev's probability, and the rule's description. The
|
|
57
|
-
hint is the rule's reference
|
|
57
|
+
hint is the rule's reference; a rule with only code to avoid shows that code,
|
|
58
|
+
labeled `avoid:`, instead. The exit code is 0 when nothing is reported and 1
|
|
58
59
|
when something is. It is also 1 when the run refuses, for example on an invalid
|
|
59
60
|
config or rule file, a missing `TYPESAFE_API_KEY`, or an unknown command or
|
|
60
61
|
flag; a refusal prints its reason.
|
|
@@ -78,10 +79,11 @@ prints a completion script.
|
|
|
78
79
|
|
|
79
80
|
`adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
|
|
80
81
|
`.test.ts`, and config files. When the working directory contains `agents/`,
|
|
81
|
-
`apps/`, or `packages/`, only those trees are read.
|
|
82
|
-
`node_modules/`, `dist/`, `coverage/`, `vendor/`,
|
|
83
|
-
`.adhere/`, `.agents/`, `.claude/`, `.direnv/`,
|
|
84
|
-
|
|
82
|
+
`apps/`, or `packages/`, only those trees are read. Below the working
|
|
83
|
+
directory, anything under `node_modules/`, `dist/`, `coverage/`, `vendor/`,
|
|
84
|
+
`e2e/`, `references/`, `.adhere/`, `.agents/`, `.claude/`, `.direnv/`,
|
|
85
|
+
`.alchemy/`, or `.vite/` is skipped; the directories above it do not count.
|
|
86
|
+
`.tsx` files are not read, and `.gitignore` is not consulted.
|
|
85
87
|
|
|
86
88
|
### Init
|
|
87
89
|
|
|
@@ -127,6 +129,7 @@ export default {
|
|
|
127
129
|
const UserId = Schema.String.pipe(Schema.brand("UserId"))
|
|
128
130
|
type UserId = typeof UserId.Type
|
|
129
131
|
`,
|
|
132
|
+
avoid: "type UserId = string", // optional: what a violation looks like
|
|
130
133
|
threshold: 0.8, // optional per-rule override
|
|
131
134
|
},
|
|
132
135
|
},
|
|
@@ -153,7 +156,8 @@ directory exists, it is read without any config.
|
|
|
153
156
|
|
|
154
157
|
A file is front matter, then a body. The first fenced code block in the body
|
|
155
158
|
is the reference; prose around it renders on GitHub and is ignored. Without a
|
|
156
|
-
fence, the whole body is the reference.
|
|
159
|
+
fence, the whole body is the reference. A fence tagged `avoid` after its
|
|
160
|
+
language holds code to avoid instead: what a violation looks like.
|
|
157
161
|
|
|
158
162
|
````md
|
|
159
163
|
---
|
|
@@ -169,13 +173,28 @@ const Port = Schema.Int.pipe(
|
|
|
169
173
|
Schema.brand("Port"),
|
|
170
174
|
);
|
|
171
175
|
```
|
|
176
|
+
|
|
177
|
+
Not this:
|
|
178
|
+
|
|
179
|
+
```ts avoid
|
|
180
|
+
const port: number = Number(process.env.PORT);
|
|
181
|
+
```
|
|
172
182
|
````
|
|
173
183
|
|
|
174
|
-
`description` is required
|
|
175
|
-
validation refuses the run with its
|
|
176
|
-
from the package root does the same
|
|
184
|
+
`description` is required, with a reference, code to avoid, or both.
|
|
185
|
+
`threshold` is optional. A file that fails validation refuses the run with its
|
|
186
|
+
path in the message. `loadRules(directory)` from the package root does the same
|
|
187
|
+
load for your own tooling.
|
|
188
|
+
|
|
189
|
+
With a reference, Jev is asked whether the file diverges from it, and code to
|
|
190
|
+
avoid, when there is some, is its example of diverging. With only code to
|
|
191
|
+
avoid, Jev is asked whether the file contains it. That suits a rule with no
|
|
192
|
+
single correct form to show, such as a hand-rolled retry loop or an error
|
|
193
|
+
caught and dropped. Neither goes in the other's place: code to avoid in the
|
|
194
|
+
reference reads to Jev as the pattern to follow.
|
|
177
195
|
|
|
178
|
-
Nested `.adhere/` directories are also discovered
|
|
196
|
+
Nested `.adhere/` directories are also discovered, except under
|
|
197
|
+
`node_modules/`, `dist/`, and the other skipped directories (above). A rule in
|
|
179
198
|
`packages/api/.adhere/data/brand-ports.md` has the same id,
|
|
180
199
|
`data/brand-ports`, but applies only to files under `packages/api/`. Root
|
|
181
200
|
`.adhere/` rules apply project-wide. If a nested rule has the same id as a root
|
|
@@ -219,8 +238,10 @@ beats all of the above for that rule.
|
|
|
219
238
|
## How a file is judged
|
|
220
239
|
|
|
221
240
|
1. One Jev request per file, with the file's numbered lines and every rule as
|
|
222
|
-
the state, and one `noul` (yes/no probability) question per rule
|
|
223
|
-
|
|
241
|
+
the state, and one `noul` (yes/no probability) question per rule: whether
|
|
242
|
+
the file diverges from the reference or, for a rule with only code to
|
|
243
|
+
avoid, whether it contains that code. Every question shares the state cost
|
|
244
|
+
of the request.
|
|
224
245
|
2. A second request only when at least one rule's probability is above its
|
|
225
246
|
threshold: one `choice` question per flagged rule over the file's non-blank
|
|
226
247
|
lines, which yields the line to report. A file with more than 255 non-blank
|
|
@@ -306,5 +327,6 @@ before it can be given trusted publishers on npmjs.com. npm checks the
|
|
|
306
327
|
workflow that started the run, so every package has two: `release.yaml`, which
|
|
307
328
|
calls the publish workflow for tag releases, and `publish.yaml`, for manual
|
|
308
329
|
runs. The publish workflow can be run manually for an already-created release
|
|
309
|
-
tag if a publish needs to be retried; it skips
|
|
310
|
-
|
|
330
|
+
tag if a publish needs to be retried; it skips every package whose version is
|
|
331
|
+
already on npm. The main package's `files` list keeps it to `bin/`, `src/`,
|
|
332
|
+
`presets/`, and `skills/`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against reference code in .adhere/, judged by Jev.",
|
|
6
6
|
"repository": {
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./src/index.ts"
|
|
19
19
|
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin",
|
|
22
|
+
"src",
|
|
23
|
+
"presets",
|
|
24
|
+
"skills"
|
|
25
|
+
],
|
|
20
26
|
"publishConfig": {
|
|
21
27
|
"access": "public"
|
|
22
28
|
},
|
|
@@ -61,10 +67,10 @@
|
|
|
61
67
|
}
|
|
62
68
|
},
|
|
63
69
|
"optionalDependencies": {
|
|
64
|
-
"@drkmttr/adhere-darwin-arm64": "0.
|
|
65
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
66
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
67
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
68
|
-
"@drkmttr/adhere-win32-x64": "0.
|
|
70
|
+
"@drkmttr/adhere-darwin-arm64": "0.4.0",
|
|
71
|
+
"@drkmttr/adhere-darwin-x64": "0.4.0",
|
|
72
|
+
"@drkmttr/adhere-linux-arm64": "0.4.0",
|
|
73
|
+
"@drkmttr/adhere-linux-x64": "0.4.0",
|
|
74
|
+
"@drkmttr/adhere-win32-x64": "0.4.0"
|
|
69
75
|
}
|
|
70
76
|
}
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -61,15 +61,22 @@ Optional prose for readers on GitHub. adhere ignores it.
|
|
|
61
61
|
```ts
|
|
62
62
|
// Correct code lifted from this repo, trimmed to the pattern.
|
|
63
63
|
```
|
|
64
|
+
|
|
65
|
+
```ts avoid
|
|
66
|
+
// Optional: the incorrect form this rule catches, as it appeared in the repo.
|
|
67
|
+
```
|
|
64
68
|
````
|
|
65
69
|
|
|
66
70
|
Rules for the rule:
|
|
67
71
|
|
|
68
72
|
- `description` is one sentence, positive, specific. Jev reads it with the
|
|
69
73
|
reference; vague words ("properly", "correctly") give it nothing to judge.
|
|
70
|
-
- The reference is real code from the repo, not invented
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
- The reference is real code from the repo, not invented, and only correct
|
|
75
|
+
code: Jev compares files to it, so incorrect code there teaches the wrong
|
|
76
|
+
thing. Incorrect code goes in a fence tagged `avoid`, which Jev reads as what
|
|
77
|
+
a violation looks like. Add one when violations have a recognizable shape,
|
|
78
|
+
ideally one found in the repo's history. A rule with no single correct form
|
|
79
|
+
can be only an `avoid` block.
|
|
73
80
|
- One pattern per file. Two patterns in one reference blur the probability.
|
|
74
81
|
- `threshold` is optional. Start without it; set it in step 5 if needed.
|
|
75
82
|
|
package/src/config.ts
CHANGED
|
@@ -6,9 +6,18 @@ export type RuleId = string;
|
|
|
6
6
|
|
|
7
7
|
export const Rule = Schema.Struct({
|
|
8
8
|
description: Schema.String,
|
|
9
|
-
|
|
9
|
+
/** Correct code: the pattern a file should follow. */
|
|
10
|
+
reference: Schema.optionalKey(Schema.String),
|
|
11
|
+
/** Incorrect code: what a violation looks like. */
|
|
12
|
+
avoid: Schema.optionalKey(Schema.String),
|
|
10
13
|
threshold: Schema.optionalKey(Schema.Finite),
|
|
11
|
-
})
|
|
14
|
+
}).check(
|
|
15
|
+
Schema.makeFilter((rule) =>
|
|
16
|
+
rule.reference !== undefined || rule.avoid !== undefined
|
|
17
|
+
? undefined
|
|
18
|
+
: "a rule needs a reference, code to avoid, or both",
|
|
19
|
+
),
|
|
20
|
+
);
|
|
12
21
|
export interface Rule extends Schema.Schema.Type<typeof Rule> {}
|
|
13
22
|
|
|
14
23
|
export type Rules = Readonly<Record<RuleId, Rule>>;
|
|
@@ -38,6 +47,25 @@ export const CONFIG_FILES = [
|
|
|
38
47
|
"adhere.config.ts",
|
|
39
48
|
".adhere.config.ts",
|
|
40
49
|
] as const;
|
|
50
|
+
/**
|
|
51
|
+
* Directories never read into, for source files or for nested `.adhere/`
|
|
52
|
+
* rules: dependency, generated, vendored, and tool trees. Matched as whole
|
|
53
|
+
* path segments below the working directory, so a repo that itself sits under
|
|
54
|
+
* one of these names is still read.
|
|
55
|
+
*/
|
|
56
|
+
export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
57
|
+
"node_modules",
|
|
58
|
+
"dist",
|
|
59
|
+
".agents",
|
|
60
|
+
".claude",
|
|
61
|
+
".direnv",
|
|
62
|
+
".alchemy",
|
|
63
|
+
"coverage",
|
|
64
|
+
".vite",
|
|
65
|
+
"references",
|
|
66
|
+
"vendor",
|
|
67
|
+
"e2e",
|
|
68
|
+
]);
|
|
41
69
|
export interface AdhereConfig extends Schema.Schema.Type<typeof AdhereConfig> {}
|
|
42
70
|
|
|
43
71
|
/** The shape a config file default-exports. */
|
package/src/init.ts
CHANGED
|
@@ -43,6 +43,16 @@ export const loadCustomerProfile = (customerId: CustomerId) =>
|
|
|
43
43
|
return yield* CustomerStore.find(customerId);
|
|
44
44
|
});
|
|
45
45
|
\`\`\`
|
|
46
|
+
|
|
47
|
+
A fence tagged \`avoid\` shows what a violation looks like. It is optional, and
|
|
48
|
+
a rule can have one without the block above.
|
|
49
|
+
|
|
50
|
+
\`\`\`ts avoid
|
|
51
|
+
export const handle = (id: string) =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
return yield* CustomerStore.find(id);
|
|
54
|
+
});
|
|
55
|
+
\`\`\`
|
|
46
56
|
`;
|
|
47
57
|
|
|
48
58
|
const CONFIG_PATH = CONFIG_FILES[0];
|
package/src/markdown.ts
CHANGED
|
@@ -14,8 +14,13 @@ import { Effect, Schema } from "effect";
|
|
|
14
14
|
* const reference = "the correct code"
|
|
15
15
|
* ```
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* ```ts avoid
|
|
18
|
+
* const avoided = "what a violation looks like"
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The reference is the first fenced code block; `avoid` in a fence's info
|
|
22
|
+
* string marks the code to avoid instead. A rule has either or both. Without a
|
|
23
|
+
* fence, the whole body is the reference. `threshold` is optional.
|
|
19
24
|
*/
|
|
20
25
|
const FrontMatter = Schema.Struct({
|
|
21
26
|
description: Schema.String,
|
|
@@ -23,7 +28,8 @@ const FrontMatter = Schema.Struct({
|
|
|
23
28
|
});
|
|
24
29
|
|
|
25
30
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
26
|
-
const FENCE = /^ {0,3}(`{3,}|~{3,})[^\n]
|
|
31
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
|
|
32
|
+
const AVOID = "avoid";
|
|
27
33
|
|
|
28
34
|
const unquote = (value: string): string => {
|
|
29
35
|
const trimmed = value.trim();
|
|
@@ -44,9 +50,35 @@ const parseFrontMatter = (block: string): Record<string, string> => {
|
|
|
44
50
|
return fields;
|
|
45
51
|
};
|
|
46
52
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
interface Fence {
|
|
54
|
+
readonly avoid: boolean;
|
|
55
|
+
readonly code: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fencesOf = (body: string): ReadonlyArray<Fence> =>
|
|
59
|
+
Array.from(body.matchAll(FENCE), (match) => ({
|
|
60
|
+
avoid: (match[2] ?? "")
|
|
61
|
+
.trim()
|
|
62
|
+
.split(/\s+/)
|
|
63
|
+
.some((word) => word.toLowerCase() === AVOID),
|
|
64
|
+
code: (match[3] ?? "").trim(),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
/** An empty block counts as no block. */
|
|
68
|
+
const nonEmpty = (code: string | undefined): string | undefined =>
|
|
69
|
+
code === undefined || code.length === 0 ? undefined : code;
|
|
70
|
+
|
|
71
|
+
/** The reference and the code to avoid, each left out when the body has none. */
|
|
72
|
+
const codeOf = (body: string): Pick<Rule, "reference" | "avoid"> => {
|
|
73
|
+
const fences = fencesOf(body);
|
|
74
|
+
const reference = nonEmpty(
|
|
75
|
+
fences.length === 0 ? body.trim() : fences.find((fence) => !fence.avoid)?.code,
|
|
76
|
+
);
|
|
77
|
+
const avoid = nonEmpty(fences.find((fence) => fence.avoid)?.code);
|
|
78
|
+
return {
|
|
79
|
+
...(reference === undefined ? {} : { reference }),
|
|
80
|
+
...(avoid === undefined ? {} : { avoid }),
|
|
81
|
+
};
|
|
50
82
|
};
|
|
51
83
|
|
|
52
84
|
export const parseRuleMarkdown = (
|
|
@@ -65,11 +97,11 @@ export const parseRuleMarkdown = (
|
|
|
65
97
|
ConfigUnavailable.make({ message: `${file}: ${problem.message}` }),
|
|
66
98
|
),
|
|
67
99
|
);
|
|
68
|
-
const
|
|
69
|
-
if (reference.
|
|
100
|
+
const code = codeOf(match[2]);
|
|
101
|
+
if (code.reference === undefined && code.avoid === undefined) {
|
|
70
102
|
return yield* ConfigUnavailable.make({
|
|
71
|
-
message: `${file}: the body
|
|
103
|
+
message: `${file}: the body needs reference code, a fence tagged avoid, or both`,
|
|
72
104
|
});
|
|
73
105
|
}
|
|
74
|
-
return { ...front,
|
|
106
|
+
return { ...front, ...code };
|
|
75
107
|
});
|
package/src/rules.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ConfigUnavailable,
|
|
3
|
+
type Rule,
|
|
4
|
+
type RuleId,
|
|
5
|
+
type RuleSource,
|
|
6
|
+
SKIPPED_DIRECTORIES,
|
|
7
|
+
} from "#config.ts";
|
|
2
8
|
import { parseRuleMarkdown } from "#markdown.ts";
|
|
3
9
|
import { Effect, FileSystem, Path, Record } from "effect";
|
|
4
10
|
|
|
@@ -31,7 +37,13 @@ const isMarkdown = (path: string): boolean => path.endsWith(".md");
|
|
|
31
37
|
const isNestedRuleFile = (relative: string): boolean => {
|
|
32
38
|
const segments = segmentsOf(relative);
|
|
33
39
|
const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
|
|
34
|
-
return
|
|
40
|
+
return (
|
|
41
|
+
adhere >= 0 &&
|
|
42
|
+
segments[adhere + 1] !== CACHE_SEGMENT &&
|
|
43
|
+
isMarkdown(relative) &&
|
|
44
|
+
// Only the path to the `.adhere/`: `.adhere/e2e/` is a rule topic, not a skip.
|
|
45
|
+
!segments.slice(0, adhere).some((segment) => SKIPPED_DIRECTORIES.has(segment))
|
|
46
|
+
);
|
|
35
47
|
};
|
|
36
48
|
|
|
37
49
|
const rulePathOf = (segments: ReadonlyArray<string>): ReadonlyArray<string> => {
|
package/src/services/Jev.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuleId, Rules } from "#config.ts";
|
|
1
|
+
import type { Rule, RuleId, Rules } from "#config.ts";
|
|
2
2
|
import { Context, type Effect, Record, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
export type { Rules };
|
|
@@ -36,12 +36,36 @@ export const needsBlocks = (lines: Lines): boolean =>
|
|
|
36
36
|
|
|
37
37
|
const stateOf = (lines: Lines, rules: Rules) => ({
|
|
38
38
|
code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
|
|
39
|
-
rules: Record.map(rules, ({ description, reference }) => ({
|
|
39
|
+
rules: Record.map(rules, ({ description, reference, avoid }) => ({
|
|
40
40
|
description,
|
|
41
|
-
reference,
|
|
41
|
+
...(reference === undefined ? {} : { reference }),
|
|
42
|
+
...(avoid === undefined ? {} : { avoid }),
|
|
42
43
|
})),
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
const field = (id: RuleId, key: "avoid" | "description" | "reference") =>
|
|
47
|
+
`state.rules["${id}"].${key}`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A rule with a reference asks whether the file diverges from it, with the
|
|
51
|
+
* code to avoid as an example when there is some. A rule without one asks
|
|
52
|
+
* whether the file contains the code to avoid.
|
|
53
|
+
*/
|
|
54
|
+
const judgeInstructions = (id: RuleId, rule: Rule): string =>
|
|
55
|
+
rule.reference === undefined
|
|
56
|
+
? `Does state.code contain the pattern shown in ${field(id, "avoid")}, which ${field(id, "description")} rules out? Answer no if nothing in this file resembles it.`
|
|
57
|
+
: `Does state.code diverge from the pattern shown in ${field(id, "reference")}${
|
|
58
|
+
rule.avoid === undefined ? "" : `, for example by doing what ${field(id, "avoid")} shows`
|
|
59
|
+
}, as described by ${field(id, "description")}? Answer no if the pattern does not apply to this file.`;
|
|
60
|
+
|
|
61
|
+
/** What the offending line does, for the line and block questions. */
|
|
62
|
+
const offense = (id: RuleId, rule: Rule): string =>
|
|
63
|
+
rule.reference === undefined
|
|
64
|
+
? `shows the pattern in ${field(id, "avoid")}`
|
|
65
|
+
: rule.avoid === undefined
|
|
66
|
+
? `diverges from ${field(id, "reference")}`
|
|
67
|
+
: `diverges from ${field(id, "reference")} or resembles ${field(id, "avoid")}`;
|
|
68
|
+
|
|
45
69
|
const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
|
|
46
70
|
const criteria: Record<string, string> = {};
|
|
47
71
|
for (let n = from; n <= Math.min(to, lines.length); n++) {
|
|
@@ -54,9 +78,9 @@ const lineCriteria = (lines: Lines, from: number, to: number): Record<string, st
|
|
|
54
78
|
export const judgeBody = (model: string, lines: Lines, rules: Rules) => ({
|
|
55
79
|
model,
|
|
56
80
|
state: stateOf(lines, rules),
|
|
57
|
-
questions: Record.map(rules, (
|
|
81
|
+
questions: Record.map(rules, (rule, id) => ({
|
|
58
82
|
type: "noul" as const,
|
|
59
|
-
instructions:
|
|
83
|
+
instructions: judgeInstructions(id, rule),
|
|
60
84
|
})),
|
|
61
85
|
});
|
|
62
86
|
|
|
@@ -68,11 +92,11 @@ export const locateBody = (
|
|
|
68
92
|
) => ({
|
|
69
93
|
model,
|
|
70
94
|
state: stateOf(lines, rules),
|
|
71
|
-
questions: Record.map(rules, (
|
|
95
|
+
questions: Record.map(rules, (rule, id) => {
|
|
72
96
|
const block = blocks?.[id];
|
|
73
97
|
return {
|
|
74
98
|
type: "choice" as const,
|
|
75
|
-
instructions: `Which line of state.code most clearly
|
|
99
|
+
instructions: `Which line of state.code most clearly ${offense(id, rule)}?`,
|
|
76
100
|
criteria:
|
|
77
101
|
block === undefined
|
|
78
102
|
? lineCriteria(lines, 1, lines.length)
|
|
@@ -92,9 +116,9 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
|
|
|
92
116
|
return {
|
|
93
117
|
model,
|
|
94
118
|
state: stateOf(lines, rules),
|
|
95
|
-
questions: Record.map(rules, (
|
|
119
|
+
questions: Record.map(rules, (rule, id) => ({
|
|
96
120
|
type: "choice" as const,
|
|
97
|
-
instructions: `Which block of state.code contains the line that most clearly
|
|
121
|
+
instructions: `Which block of state.code contains the line that most clearly ${offense(id, rule)}?`,
|
|
98
122
|
criteria,
|
|
99
123
|
})),
|
|
100
124
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Context, Effect, FileSystem, Layer, Path } from "effect";
|
|
2
|
+
import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
|
|
2
3
|
import type { ScannedFile } from "#models/Audit.ts";
|
|
3
4
|
import { WalkUnavailable } from "#models/Audit.ts";
|
|
4
5
|
|
|
@@ -20,21 +21,15 @@ export class SourceWalker extends Context.Service<
|
|
|
20
21
|
* trees. Otherwise read the working directory itself.
|
|
21
22
|
*/
|
|
22
23
|
const WORKSPACE_DIRS = ["agents", "apps", "packages"] as const;
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"/coverage/",
|
|
33
|
-
"/.vite/",
|
|
34
|
-
"/references/",
|
|
35
|
-
"/vendor/",
|
|
36
|
-
"/e2e/",
|
|
37
|
-
] as const;
|
|
24
|
+
/**
|
|
25
|
+
* A path, relative to the root being read, through a skipped directory or
|
|
26
|
+
* through `.adhere/`, whose files are rules and cache rather than source.
|
|
27
|
+
* Relative, so the directories above the working directory never count.
|
|
28
|
+
*/
|
|
29
|
+
export const isInSkippedTree = (relative: string): boolean =>
|
|
30
|
+
relative
|
|
31
|
+
.split(/[\\/]/)
|
|
32
|
+
.some((segment) => segment === ADHERE_DIRECTORY || SKIPPED_DIRECTORIES.has(segment));
|
|
38
33
|
|
|
39
34
|
const isInside = (file: string, root: string): boolean =>
|
|
40
35
|
file === root || file.startsWith(`${root}/`);
|
|
@@ -45,7 +40,6 @@ const isScannable = (file: string, self: string): boolean =>
|
|
|
45
40
|
!file.endsWith(".test.ts") &&
|
|
46
41
|
!file.endsWith("/adhere.config.ts") &&
|
|
47
42
|
!file.endsWith("/.adhere.config.ts") &&
|
|
48
|
-
SKIPS.every((skip) => !file.includes(skip)) &&
|
|
49
43
|
// The audit's own detector patterns are data, not violations of themselves.
|
|
50
44
|
!isInside(file, self);
|
|
51
45
|
|
|
@@ -57,7 +51,10 @@ const rooted = (root: string, paths: ReadonlyArray<string>) => paths.map((rel) =
|
|
|
57
51
|
|
|
58
52
|
/** What the root actually contains: only paths the audit reads. */
|
|
59
53
|
const scannable = (root: string, paths: ReadonlyArray<string>, self: string) =>
|
|
60
|
-
rooted(
|
|
54
|
+
rooted(
|
|
55
|
+
root,
|
|
56
|
+
paths.filter((relative) => !isInSkippedTree(relative)),
|
|
57
|
+
).filter((file) => isScannable(file, self));
|
|
61
58
|
|
|
62
59
|
/** One root's paths, filtered to what the audit reads. */
|
|
63
60
|
const listRoot = Effect.fn("SourceWalker.listRoot")(
|
package/src/workflows/audit.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { type Crypto, Effect, Record } from "effect";
|
|
|
10
10
|
export interface Finding {
|
|
11
11
|
readonly rule: RuleId;
|
|
12
12
|
readonly description: string;
|
|
13
|
-
readonly reference
|
|
13
|
+
readonly reference?: string;
|
|
14
|
+
readonly avoid?: string;
|
|
14
15
|
readonly file: string;
|
|
15
16
|
readonly line: number;
|
|
16
17
|
readonly snippet: string;
|
|
@@ -43,6 +44,19 @@ const fileResult = (
|
|
|
43
44
|
|
|
44
45
|
const isEmpty = Record.isEmptyReadonlyRecord;
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* What a judgment depends on besides the file. A rule without code to avoid
|
|
49
|
+
* hashes what every rule hashed before `avoid` existed, so its cached
|
|
50
|
+
* judgments survive the upgrade.
|
|
51
|
+
*/
|
|
52
|
+
const fingerprintOf = (model: string, rule: Rule) =>
|
|
53
|
+
sha256(
|
|
54
|
+
model +
|
|
55
|
+
rule.description +
|
|
56
|
+
(rule.reference ?? "") +
|
|
57
|
+
(rule.avoid === undefined ? "" : `\u0000avoid\u0000${rule.avoid}`),
|
|
58
|
+
);
|
|
59
|
+
|
|
46
60
|
export const runAudit: Effect.Effect<
|
|
47
61
|
AuditResult,
|
|
48
62
|
WalkUnavailable | JevUnavailable,
|
|
@@ -66,7 +80,7 @@ export const runAudit: Effect.Effect<
|
|
|
66
80
|
Object.entries(configuredRules(file.path)),
|
|
67
81
|
([id, rule]) =>
|
|
68
82
|
Effect.map(
|
|
69
|
-
|
|
83
|
+
fingerprintOf(config.model, rule),
|
|
70
84
|
(fingerprint) =>
|
|
71
85
|
[
|
|
72
86
|
id,
|
|
@@ -116,6 +130,7 @@ export const runAudit: Effect.Effect<
|
|
|
116
130
|
rule: id,
|
|
117
131
|
description: k.rule.description,
|
|
118
132
|
reference: k.rule.reference,
|
|
133
|
+
avoid: k.rule.avoid,
|
|
119
134
|
file: file.path,
|
|
120
135
|
line: judgment.line,
|
|
121
136
|
snippet: judgment.snippet ?? "",
|
package/src/workflows/format.ts
CHANGED
|
@@ -24,8 +24,8 @@ export interface RenderOptions {
|
|
|
24
24
|
|
|
25
25
|
const isBlank = (line: string): boolean => line.trim().length === 0;
|
|
26
26
|
|
|
27
|
-
const
|
|
28
|
-
const lines =
|
|
27
|
+
const codeLines = (code: string): ReadonlyArray<string> => {
|
|
28
|
+
const lines = code.split("\n");
|
|
29
29
|
const first = lines.findIndex((line) => !isBlank(line));
|
|
30
30
|
if (first < 0) return [];
|
|
31
31
|
let last = lines.length - 1;
|
|
@@ -36,12 +36,16 @@ const referenceLines = (reference: string): ReadonlyArray<string> => {
|
|
|
36
36
|
/**
|
|
37
37
|
* One diagnostic, in the frame `vp lint` prints on a terminal: a red header,
|
|
38
38
|
* the offending line, a pink underline, and the reference code as the hint.
|
|
39
|
+
* A rule without a reference shows its code to avoid instead, labeled so.
|
|
39
40
|
*/
|
|
40
41
|
const frame = (finding: Finding, options: RenderOptions): ReadonlyArray<string> => {
|
|
41
42
|
const color = options.color === true;
|
|
42
43
|
const digits = String(finding.line);
|
|
43
44
|
const gutter = " ".repeat(digits.length + 2);
|
|
44
|
-
const [
|
|
45
|
+
const [label, code] =
|
|
46
|
+
finding.reference === undefined ? ["avoid", finding.avoid ?? ""] : ["hint", finding.reference];
|
|
47
|
+
const prefix = ` ${label}: `;
|
|
48
|
+
const [hint = "", ...rest] = codeLines(code);
|
|
45
49
|
const header = `${finding.rule} (${finding.probability.toFixed(2)})`;
|
|
46
50
|
return [
|
|
47
51
|
` ${red("×", color)} ${red(header, color)}: ${red(finding.description, color)}`,
|
|
@@ -49,8 +53,8 @@ const frame = (finding: Finding, options: RenderOptions): ReadonlyArray<string>
|
|
|
49
53
|
` ${dim(digits, color)} │ ${finding.snippet}`,
|
|
50
54
|
`${gutter}· ${pink("─".repeat(Math.max(1, finding.snippet.trim().length)), color)}`,
|
|
51
55
|
`${gutter}╰────`,
|
|
52
|
-
`${helpTint(
|
|
53
|
-
...rest.map((line) => (isBlank(line) ? "" :
|
|
56
|
+
`${helpTint(prefix, color)}${hint}`,
|
|
57
|
+
...rest.map((line) => (isBlank(line) ? "" : `${" ".repeat(prefix.length)}${line}`)),
|
|
54
58
|
];
|
|
55
59
|
};
|
|
56
60
|
|
package/.adhere/config.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: An error raised while reading a config, rule, or cache file names that file in its message, so the user can open it.
|
|
3
|
-
threshold: 0.8
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Adhere's own rule. A refusal is the only thing the user sees, so it points
|
|
7
|
-
at the file it is about.
|
|
8
|
-
|
|
9
|
-
```ts
|
|
10
|
-
const front =
|
|
11
|
-
yield *
|
|
12
|
-
Schema.decodeUnknownEffect(FrontMatter)(fields).pipe(
|
|
13
|
-
Effect.mapError((problem) =>
|
|
14
|
-
ConfigUnavailable.make({ message: `${file}: ${problem.message}` }),
|
|
15
|
-
),
|
|
16
|
-
);
|
|
17
|
-
```
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
pull_request:
|
|
5
|
-
push:
|
|
6
|
-
branches: [main]
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
verify:
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
steps:
|
|
12
|
-
- uses: actions/checkout@v4
|
|
13
|
-
- uses: oven-sh/setup-bun@v2
|
|
14
|
-
with:
|
|
15
|
-
bun-version: "1.4.2"
|
|
16
|
-
- run: bun install --frozen-lockfile
|
|
17
|
-
- run: bun run typecheck
|
|
18
|
-
- run: bun run test
|
|
19
|
-
- run: bun run build:npm
|
|
20
|
-
- name: Run the launcher against the linux-x64 package
|
|
21
|
-
run: |
|
|
22
|
-
# Outside the checkout, where the launcher has no source to fall back to.
|
|
23
|
-
launcher="$RUNNER_TEMP/launcher"
|
|
24
|
-
mkdir -p "$launcher/node_modules/@drkmttr"
|
|
25
|
-
cp -r bin "$launcher/bin"
|
|
26
|
-
ln -s "$PWD/dist/npm/linux-x64" "$launcher/node_modules/@drkmttr/adhere-linux-x64"
|
|
27
|
-
node "$launcher/bin/adhere.js" --version
|