@drkmttr/adhere 0.9.5 → 0.11.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 +406 -90
- package/package.json +7 -7
- 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/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/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/gen-for-sequencing.md +7 -3
- 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 +7 -3
- package/presets/effect/data/variants-are-tagged-unions.md +7 -3
- 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 +7 -3
- 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/presets/react/effects/clean-up-what-they-start.md +23 -0
- package/presets/react/effects/event-logic-in-handlers.md +29 -0
- package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
- package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
- package/presets/react/effects/notify-parents-in-handlers.md +29 -0
- package/presets/react/server/no-private-data-to-client.md +23 -0
- package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
- package/presets/react/state/no-contradictory-state.md +17 -0
- package/presets/react/state/no-props-copied-into-state.md +23 -0
- package/presets/react/state/store-ids-not-copies.md +20 -0
- package/presets/security/constant-time-secret-compare.md +22 -0
- package/presets/security/no-secrets-in-output.md +19 -0
- package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
- package/presets/security/parameterized-queries.md +16 -0
- package/presets/typescript/async/concurrent-independent-work.md +23 -0
- package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
- package/presets/typescript/design/no-argument-mutation.md +20 -0
- package/presets/typescript/errors/no-swallowed-errors.md +29 -0
- package/presets/typescript/errors/release-on-every-path.md +29 -0
- package/presets/typescript/testing/assert-outcomes.md +22 -0
- package/presets/typescript/testing/independent-tests.md +41 -0
- package/presets/typescript/types/assertions-only-when-proven.md +20 -0
- package/presets/typescript/types/derive-dont-restate.md +24 -0
- package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
- package/presets/typescript/types/validate-external-data.md +21 -0
- package/skills/adhere/SKILL.md +41 -12
- package/skills/adhere-fix/SKILL.md +180 -0
- package/src/cli.ts +59 -14
- package/src/comments.ts +36 -0
- package/src/config.ts +126 -14
- package/src/contradictions.ts +10 -2
- package/src/excerpt.ts +78 -58
- package/src/highlight.ts +2 -0
- package/src/index.ts +1 -1
- package/src/init.ts +119 -12
- package/src/main.ts +0 -1
- package/src/markdown.ts +83 -14
- package/src/models/Audit.ts +2 -0
- package/src/presets.ts +12 -3
- package/src/rules.ts +36 -3
- package/src/services/AdhereConfig.ts +36 -13
- package/src/services/AuditCache.ts +31 -5
- package/src/services/Jev.http.ts +76 -11
- package/src/services/Jev.ts +191 -73
- package/src/services/SourceWalker.ts +51 -13
- package/src/suppress.ts +119 -0
- package/src/version.ts +25 -1
- package/src/workflows/audit.ts +192 -40
- package/src/workflows/format.ts +71 -24
- package/presets/alchemy/data/parameterized-sql.md +0 -13
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -15
- package/presets/effect/basics/external-calls-are-resilient.md +0 -17
package/src/main.ts
CHANGED
|
@@ -49,7 +49,6 @@ const report = (cause: Cause.Cause<unknown>): Effect.Effect<void> => {
|
|
|
49
49
|
|
|
50
50
|
Command.run(cli, { version }).pipe(
|
|
51
51
|
Effect.tapCause(report),
|
|
52
|
-
// oxlint-disable-next-line effecttsgo/strict-effect-provide -- this executable entrypoint supplies Bun's process services and the logger.
|
|
53
52
|
Effect.provide(Layer.merge(BunServices.layer, logger)),
|
|
54
53
|
(program) => BunRuntime.runMain(program, { disableErrorReporting: true }),
|
|
55
54
|
);
|
package/src/markdown.ts
CHANGED
|
@@ -10,27 +10,46 @@ import { Effect, Schema } from "effect";
|
|
|
10
10
|
* ---
|
|
11
11
|
* Any prose, rendered on GitHub and ignored by adhere.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* ## Must
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
14
16
|
* const written = "code that must be written this way"
|
|
15
17
|
* ```
|
|
16
18
|
*
|
|
17
|
-
*
|
|
19
|
+
* ## Never
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
18
22
|
* const avoided = "code that must never be written this way"
|
|
19
23
|
* ```
|
|
20
24
|
*
|
|
21
|
-
* A
|
|
22
|
-
* `never` for a requirement, `should` and `should not` for a guideline.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
25
|
+
* A heading names the code in its section with RFC 2119's words: `must` and
|
|
26
|
+
* `never` for a requirement, `should` and `should not` for a guideline. So
|
|
27
|
+
* does a fence's info string, as in `ts never`, which GitHub does not show; a
|
|
28
|
+
* fence's own word wins over its heading's. The first fence under each word is
|
|
29
|
+
* that example, and a rule has either or both. An untagged fence outside those
|
|
30
|
+
* sections is the code to write, and without a fence the whole body is.
|
|
31
|
+
* `avoid`, the word before 0.7, means `never`. `threshold` is optional, and
|
|
32
|
+
* so is `tests`: `only` for a rule about test files, which rules otherwise
|
|
33
|
+
* skip, and `include` for a rule that judges them too. So is `level`:
|
|
34
|
+
* `warning` reports the rule's findings as warnings, which do not fail a run.
|
|
35
|
+
* `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
|
|
36
|
+
* one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
|
|
26
37
|
*/
|
|
38
|
+
const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
|
|
39
|
+
|
|
27
40
|
const FrontMatter = Schema.Struct({
|
|
28
41
|
description: Schema.String,
|
|
29
42
|
threshold: Schema.optionalKey(Schema.FiniteFromString),
|
|
43
|
+
tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
|
|
44
|
+
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
45
|
+
appliesTo: Schema.optionalKey(Matchers),
|
|
46
|
+
excludeIf: Schema.optionalKey(Matchers),
|
|
30
47
|
});
|
|
31
48
|
|
|
32
49
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
33
50
|
const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
|
|
51
|
+
/** An ATX heading: its level as `#`s, then its text, without a closing run of `#`s. */
|
|
52
|
+
const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/gm;
|
|
34
53
|
|
|
35
54
|
const unquote = (value: string): string => {
|
|
36
55
|
const trimmed = value.trim();
|
|
@@ -54,11 +73,17 @@ const parseFrontMatter = (block: string): Record<string, string> => {
|
|
|
54
73
|
type Word = Example["word"];
|
|
55
74
|
|
|
56
75
|
interface Fence {
|
|
57
|
-
/** The word
|
|
76
|
+
/** The word its info string, or else its heading, names the code with; none for untagged code. */
|
|
58
77
|
readonly word: Word | undefined;
|
|
59
78
|
readonly code: string;
|
|
60
79
|
}
|
|
61
80
|
|
|
81
|
+
interface Heading {
|
|
82
|
+
readonly level: number;
|
|
83
|
+
/** The word the heading names its section's code with; none for any other heading. */
|
|
84
|
+
readonly word: Word | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
/** `must not`, like `never` and the older `avoid`, names code never to write. */
|
|
63
88
|
const wordOf = (info: string): Word | undefined => {
|
|
64
89
|
const words = info.trim().toLowerCase().split(/\s+/);
|
|
@@ -69,11 +94,55 @@ const wordOf = (info: string): Word | undefined => {
|
|
|
69
94
|
return undefined;
|
|
70
95
|
};
|
|
71
96
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
97
|
+
const HEADING_WORDS: ReadonlySet<string> = new Set([
|
|
98
|
+
"must",
|
|
99
|
+
"must not",
|
|
100
|
+
"never",
|
|
101
|
+
"avoid",
|
|
102
|
+
"should",
|
|
103
|
+
"should not",
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A heading names a word only when it is the word alone, in any case and with
|
|
108
|
+
* or without a colon: `## Never` does, `## Why never` does not.
|
|
109
|
+
*/
|
|
110
|
+
const headingWordOf = (text: string): Word | undefined => {
|
|
111
|
+
const phrase = text.trim().replace(/:$/, "").trim().toLowerCase().split(/\s+/).join(" ");
|
|
112
|
+
return HEADING_WORDS.has(phrase) ? wordOf(phrase) : undefined;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The headings open after `text`, outermost first, given those open before
|
|
117
|
+
* it. A heading closes the sections at its level and deeper, so a deeper
|
|
118
|
+
* heading stays inside the section above it.
|
|
119
|
+
*/
|
|
120
|
+
const openAfter = (open: ReadonlyArray<Heading>, text: string): ReadonlyArray<Heading> =>
|
|
121
|
+
Array.from(text.matchAll(HEADING)).reduce<ReadonlyArray<Heading>>((headings, match) => {
|
|
122
|
+
const level = (match[1] ?? "#").length;
|
|
123
|
+
return [
|
|
124
|
+
...headings.filter((heading) => heading.level < level),
|
|
125
|
+
{ level, word: headingWordOf(match[2] ?? "") },
|
|
126
|
+
];
|
|
127
|
+
}, open);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Each fence, under the word its info string names or, without one, the word
|
|
131
|
+
* of the innermost heading over it that names one.
|
|
132
|
+
*/
|
|
133
|
+
const fencesOf = (body: string): ReadonlyArray<Fence> => {
|
|
134
|
+
const fences: Array<Fence> = [];
|
|
135
|
+
let open: ReadonlyArray<Heading> = [];
|
|
136
|
+
let read = 0;
|
|
137
|
+
for (const match of body.matchAll(FENCE)) {
|
|
138
|
+
// Only headings between fences count: a `#` line inside one is code.
|
|
139
|
+
open = openAfter(open, body.slice(read, match.index));
|
|
140
|
+
read = match.index + match[0].length;
|
|
141
|
+
const section = open.findLast((heading) => heading.word !== undefined)?.word;
|
|
142
|
+
fences.push({ word: wordOf(match[2] ?? "") ?? section, code: (match[3] ?? "").trim() });
|
|
143
|
+
}
|
|
144
|
+
return fences;
|
|
145
|
+
};
|
|
77
146
|
|
|
78
147
|
/** An empty block counts as no block. */
|
|
79
148
|
const nonEmpty = (code: string | undefined): string | undefined =>
|
|
@@ -134,7 +203,7 @@ export const parseRuleMarkdown = (
|
|
|
134
203
|
}
|
|
135
204
|
if (!requirement && !guideline) {
|
|
136
205
|
return yield* ConfigUnavailable.make({
|
|
137
|
-
message: `${file}: the body needs code, in a fence
|
|
206
|
+
message: `${file}: the body needs code, in a fence under a Must, Never, Should, or Should not heading, or untagged`,
|
|
138
207
|
});
|
|
139
208
|
}
|
|
140
209
|
return { ...front, ...code };
|
package/src/models/Audit.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Schema } from "effect";
|
|
|
3
3
|
export interface ScannedFile {
|
|
4
4
|
readonly path: string;
|
|
5
5
|
readonly lines: ReadonlyArray<string>;
|
|
6
|
+
/** A test file, which only the rules that say `tests` judge. */
|
|
7
|
+
readonly test?: boolean;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
/** A source walker's refusal: the tree it was asked to read is not readable. */
|
package/src/presets.ts
CHANGED
|
@@ -4,10 +4,13 @@ import type { Preset } from "#config.ts";
|
|
|
4
4
|
* `presets/` sits next to `src/` in a checkout. In a compiled executable
|
|
5
5
|
* (`bun build --compile --asset ./presets`) Bun embeds it under the bundle's
|
|
6
6
|
* own directory instead, hence the fork on `Bun.isStandaloneExecutable`, which
|
|
7
|
-
* is the check Bun's docs give for this. Measured on Bun 1.4.
|
|
8
|
-
*
|
|
7
|
+
* is the check Bun's docs give for this. Measured on Bun 1.4. It is read off
|
|
8
|
+
* `globalThis` for Vitest, which imports this module on Node, and for a
|
|
9
|
+
* project that type-checks this package's source without Bun's types.
|
|
9
10
|
*/
|
|
10
|
-
const isStandaloneExecutable =
|
|
11
|
+
const isStandaloneExecutable =
|
|
12
|
+
(globalThis as { readonly Bun?: { readonly isStandaloneExecutable?: boolean } }).Bun
|
|
13
|
+
?.isStandaloneExecutable === true;
|
|
11
14
|
|
|
12
15
|
const presetsRoot = isStandaloneExecutable
|
|
13
16
|
? new URL("./presets/", import.meta.url)
|
|
@@ -20,6 +23,9 @@ const presetsRoot = isStandaloneExecutable
|
|
|
20
23
|
const topics = {
|
|
21
24
|
effect: ["basics", "config", "data", "errors", "services", "testing"],
|
|
22
25
|
alchemy: ["apis", "data", "durable", "providers", "resources", "runtime", "secrets", "security"],
|
|
26
|
+
typescript: ["async", "design", "errors", "testing", "types"],
|
|
27
|
+
react: ["effects", "server", "state"],
|
|
28
|
+
security: [],
|
|
23
29
|
} as const;
|
|
24
30
|
|
|
25
31
|
type Topics = typeof topics;
|
|
@@ -54,6 +60,9 @@ export const presetOf = (name: PresetName): BuiltInPreset => {
|
|
|
54
60
|
export const presets: Readonly<Record<WholePreset, BuiltInPreset>> = {
|
|
55
61
|
effect: presetOf("effect"),
|
|
56
62
|
alchemy: presetOf("alchemy"),
|
|
63
|
+
typescript: presetOf("typescript"),
|
|
64
|
+
react: presetOf("react"),
|
|
65
|
+
security: presetOf("security"),
|
|
57
66
|
};
|
|
58
67
|
|
|
59
68
|
/** The directory each whole preset reads, for checking `topics` against it. */
|
package/src/rules.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ConfigUnavailable,
|
|
3
|
+
type Override,
|
|
3
4
|
type Rule,
|
|
4
5
|
type RuleId,
|
|
5
6
|
type RuleSource,
|
|
@@ -26,12 +27,14 @@ export interface RuleEntry {
|
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* A rule's id as output shows it: a preset's rule with the preset's name
|
|
29
|
-
* first, as in effect/basics/
|
|
30
|
+
* first, as in effect/basics/gen-for-sequencing, so a report that
|
|
30
31
|
* mixes presets with the project's own rules says where each came from. The
|
|
31
32
|
* project's own rules show their `.adhere/` ids as they are.
|
|
32
33
|
*/
|
|
33
|
-
export const shownId = (entry: {
|
|
34
|
-
|
|
34
|
+
export const shownId = (entry: {
|
|
35
|
+
readonly id: RuleId;
|
|
36
|
+
readonly preset?: string | undefined;
|
|
37
|
+
}): string => (entry.preset === undefined ? entry.id : `${entry.preset}/${entry.id}`);
|
|
35
38
|
|
|
36
39
|
export type RuleSet = ReadonlyArray<RuleEntry>;
|
|
37
40
|
|
|
@@ -170,6 +173,36 @@ export const materializeRules = Effect.fn("materializeRules")(function* (
|
|
|
170
173
|
export const globalRuleSet = (rules: Readonly<Record<RuleId, Rule>>, root: string) =>
|
|
171
174
|
Object.entries(rules).map(([id, rule]): RuleEntry => ({ id, rule, scope: root }));
|
|
172
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The rules as a config's overrides leave them, each found by the id a report
|
|
178
|
+
* names it with: a rule set `off` gone, and any other with the level and
|
|
179
|
+
* threshold its override gives, over its own.
|
|
180
|
+
*/
|
|
181
|
+
export const withOverrides = (
|
|
182
|
+
entries: RuleSet,
|
|
183
|
+
overrides: Readonly<Record<string, Override>>,
|
|
184
|
+
): RuleSet =>
|
|
185
|
+
entries.flatMap((entry) => {
|
|
186
|
+
const override = overrides[shownId(entry)];
|
|
187
|
+
if (override === undefined) return [entry];
|
|
188
|
+
const { level, threshold }: Exclude<Override, string> =
|
|
189
|
+
typeof override === "string" ? { level: override } : override;
|
|
190
|
+
if (level === "off") return [];
|
|
191
|
+
const rule: Rule = {
|
|
192
|
+
...entry.rule,
|
|
193
|
+
...(level === undefined ? {} : { level }),
|
|
194
|
+
...(threshold === undefined ? {} : { threshold }),
|
|
195
|
+
};
|
|
196
|
+
return [{ ...entry, rule }];
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether a rule judges a file: a test file only when the rule says `tests`,
|
|
201
|
+
* and any other file unless it says `tests: only`.
|
|
202
|
+
*/
|
|
203
|
+
export const judgesFile = (rule: Rule, test: boolean): boolean =>
|
|
204
|
+
test ? rule.tests !== undefined : rule.tests !== "only";
|
|
205
|
+
|
|
173
206
|
/**
|
|
174
207
|
* The flat rule map for one source file. Root rules apply everywhere. When a
|
|
175
208
|
* nested `.adhere/` defines the same id, the deepest matching scope wins.
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ADHERE_DIRECTORY,
|
|
3
|
-
|
|
3
|
+
AdhereConfig as AdhereConfigSchema,
|
|
4
4
|
CONFIG_FILES,
|
|
5
5
|
ConfigUnavailable,
|
|
6
6
|
decodeConfig,
|
|
7
7
|
type Loaded,
|
|
8
|
-
type
|
|
8
|
+
type Flags,
|
|
9
9
|
type Preset,
|
|
10
10
|
presetsOf,
|
|
11
11
|
type ResolvedConfig,
|
|
12
12
|
resolveConfig,
|
|
13
13
|
} from "#config.ts";
|
|
14
|
-
import { type PresetName, presetOf, wholePresetOf } from "#presets.ts";
|
|
14
|
+
import { type PresetName, presetOf, presets, wholePresetOf } from "#presets.ts";
|
|
15
15
|
import {
|
|
16
16
|
applicableRules,
|
|
17
17
|
globalRuleSet,
|
|
18
18
|
loadAdhereRuleSet,
|
|
19
19
|
materializeRules,
|
|
20
20
|
type RuleSet,
|
|
21
|
+
shownId,
|
|
22
|
+
withOverrides,
|
|
21
23
|
} from "#rules.ts";
|
|
22
24
|
import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
|
|
23
25
|
|
|
@@ -50,13 +52,23 @@ const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetN
|
|
|
50
52
|
return [name, { ...preset, rules } satisfies Loaded<Preset>] as const;
|
|
51
53
|
});
|
|
52
54
|
|
|
55
|
+
/** Every rule of every built-in preset, by the id a report names it with. */
|
|
56
|
+
const everyPresetRule = Effect.fn("AdhereConfig.everyPresetRule")(function* (base: string) {
|
|
57
|
+
const loaded = yield* Effect.forEach(Object.entries(presets), ([name, preset]) =>
|
|
58
|
+
Effect.map(materializeRules(preset.rules, base), (rules) =>
|
|
59
|
+
Object.keys(rules).map((id) => `${name}/${id}`),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
return new Set(loaded.flat());
|
|
63
|
+
});
|
|
64
|
+
|
|
53
65
|
/**
|
|
54
66
|
* The config file in the working directory (one of `CONFIG_FILES`) with the
|
|
55
|
-
* command-line
|
|
67
|
+
* command-line flags applied. The file is optional when `.adhere/` holds
|
|
56
68
|
* rules or a preset is named. Rule directories resolve against the working
|
|
57
69
|
* directory.
|
|
58
70
|
*/
|
|
59
|
-
export const AdhereConfigLive = (
|
|
71
|
+
export const AdhereConfigLive = (flags: Flags) =>
|
|
60
72
|
Layer.effect(AdhereConfig)(
|
|
61
73
|
Effect.gen(function* () {
|
|
62
74
|
const path = yield* Path.Path;
|
|
@@ -73,8 +85,8 @@ export const AdhereConfigLive = (overrides: Overrides) =>
|
|
|
73
85
|
});
|
|
74
86
|
}
|
|
75
87
|
const configFile = found[0];
|
|
76
|
-
const hasPreset =
|
|
77
|
-
const config:
|
|
88
|
+
const hasPreset = flags.presets !== undefined && flags.presets.length > 0;
|
|
89
|
+
const config: typeof AdhereConfigSchema.Type =
|
|
78
90
|
configFile === undefined
|
|
79
91
|
? yield* decodeConfig({})
|
|
80
92
|
: yield* loadConfigFile(path.join(cwd, configFile));
|
|
@@ -90,10 +102,10 @@ export const AdhereConfigLive = (overrides: Overrides) =>
|
|
|
90
102
|
? discoveredEntries
|
|
91
103
|
: globalRuleSet(yield* materializeRules(config.rules ?? {}, cwd), cwd);
|
|
92
104
|
const rules = applicableRules(cwd, projectEntries);
|
|
93
|
-
const registry = yield* Effect.forEach(presetsOf(config,
|
|
105
|
+
const registry = yield* Effect.forEach(presetsOf(config, flags), (name) =>
|
|
94
106
|
loadPreset(name, cwd),
|
|
95
107
|
).pipe(Effect.map(Record.fromEntries));
|
|
96
|
-
const resolved = resolveConfig({ ...config, rules },
|
|
108
|
+
const resolved = resolveConfig({ ...config, rules }, flags, registry);
|
|
97
109
|
const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
|
|
98
110
|
globalRuleSet(preset.rules, cwd).map((entry) => ({
|
|
99
111
|
...entry,
|
|
@@ -103,9 +115,20 @@ export const AdhereConfigLive = (overrides: Overrides) =>
|
|
|
103
115
|
yield* Effect.logDebug(
|
|
104
116
|
`config: ${configFile ?? "no config file"}; presets ${Object.keys(registry).join(", ") || "none"}; ${presetEntries.length} preset and ${projectEntries.length} project rules; model ${resolved.model}, threshold ${resolved.threshold}${resolved.rpm === undefined ? "" : `, at most ${resolved.rpm} requests a minute`}`,
|
|
105
117
|
);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
const entries = [...presetEntries, ...projectEntries];
|
|
119
|
+
const overrides = config.overrides ?? {};
|
|
120
|
+
const loaded = new Set(entries.map(shownId));
|
|
121
|
+
const elsewhere = Object.keys(overrides).filter((id) => !loaded.has(id));
|
|
122
|
+
if (elsewhere.length > 0) {
|
|
123
|
+
// A rule of a preset this run leaves out may be overridden; one no rule has is a typo.
|
|
124
|
+
const every = yield* everyPresetRule(cwd);
|
|
125
|
+
const unknown = elsewhere.filter((id) => !every.has(id));
|
|
126
|
+
if (unknown.length > 0) {
|
|
127
|
+
return yield* ConfigUnavailable.make({
|
|
128
|
+
message: `overrides names ${unknown.join(", ")}, which is no preset or project rule. A preset's rule is named with its preset first, as in alchemy/providers/idempotent-delete.`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return AdhereConfig.of({ ...resolved, scopedRules: withOverrides(entries, overrides) });
|
|
110
133
|
}),
|
|
111
134
|
);
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import { CACHE_DIRECTORY } from "#config.ts";
|
|
2
2
|
import { Context, Crypto, Effect, FileSystem, Layer, Option, Path, Record, Schema } from "effect";
|
|
3
3
|
|
|
4
|
+
/** Jev's probability for each of a rule's `appliesTo` and `excludeIf` matchers, when it has them. */
|
|
5
|
+
const MatcherFields = {
|
|
6
|
+
appliesTo: Schema.optionalKey(Schema.Array(Schema.Finite)),
|
|
7
|
+
excludeIf: Schema.optionalKey(Schema.Array(Schema.Finite)),
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Lines of a file, first to last. */
|
|
11
|
+
const Lines = Schema.Struct({ first: Schema.Finite, last: Schema.Finite });
|
|
12
|
+
/** The lines of the section a finding is in, once located. */
|
|
13
|
+
const Section = Schema.optionalKey(Lines);
|
|
14
|
+
/** The lines in its section the code that breaks the rule starts and ends on, when Jev named them. */
|
|
15
|
+
const Located = Schema.optionalKey(Lines);
|
|
16
|
+
|
|
17
|
+
/** Jev's probability, asked with the section, that the file shows enough to decide the rule. */
|
|
18
|
+
const Sufficiency = Schema.optionalKey(Schema.Finite);
|
|
19
|
+
|
|
4
20
|
/** A rule's judgment as a run holds it: Jev's answer, with the fingerprint of the rule's text. */
|
|
5
21
|
export const Judgment = Schema.Struct({
|
|
6
22
|
fingerprint: Schema.String,
|
|
7
23
|
probability: Schema.Finite,
|
|
8
|
-
|
|
24
|
+
section: Section,
|
|
25
|
+
lines: Located,
|
|
26
|
+
sufficiency: Sufficiency,
|
|
27
|
+
...MatcherFields,
|
|
9
28
|
});
|
|
10
29
|
export interface Judgment extends Schema.Schema.Type<typeof Judgment> {}
|
|
11
30
|
|
|
@@ -21,10 +40,17 @@ export const CacheEntry = Schema.Struct({
|
|
|
21
40
|
});
|
|
22
41
|
export interface CacheEntry extends Schema.Schema.Type<typeof CacheEntry> {}
|
|
23
42
|
|
|
24
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Jev's answer about some code for one rule text: the probability, the
|
|
45
|
+
* section, lines, and sufficiency once located, and its matchers' scores. An answer from before
|
|
46
|
+
* sections held a line instead, under a fingerprint no rule has now.
|
|
47
|
+
*/
|
|
25
48
|
export const Answer = Schema.Struct({
|
|
26
49
|
probability: Schema.Finite,
|
|
27
|
-
|
|
50
|
+
section: Section,
|
|
51
|
+
lines: Located,
|
|
52
|
+
sufficiency: Sufficiency,
|
|
53
|
+
...MatcherFields,
|
|
28
54
|
});
|
|
29
55
|
export interface Answer extends Schema.Schema.Type<typeof Answer> {}
|
|
30
56
|
|
|
@@ -98,13 +124,13 @@ const sorted = <A>(record: Readonly<Record<string, A>>): Record<string, A> =>
|
|
|
98
124
|
|
|
99
125
|
const textOf = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
|
|
100
126
|
|
|
101
|
-
/** One answer per rule text: one that has its
|
|
127
|
+
/** One answer per rule text: one that has its section over one that has not, otherwise the first. */
|
|
102
128
|
const unionOf = (entries: ReadonlyArray<Answers>): Answers => {
|
|
103
129
|
const union: Record<string, Answer> = {};
|
|
104
130
|
for (const answers of entries) {
|
|
105
131
|
for (const [fingerprint, answer] of Object.entries(answers)) {
|
|
106
132
|
const held = union[fingerprint];
|
|
107
|
-
if (held === undefined || (held.
|
|
133
|
+
if (held === undefined || (held.section === undefined && answer.section !== undefined)) {
|
|
108
134
|
union[fingerprint] = answer;
|
|
109
135
|
}
|
|
110
136
|
}
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { RuleId } from "#config.ts";
|
|
2
|
+
import { sectionsOf } from "#excerpt.ts";
|
|
2
3
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
3
4
|
import { Credentials } from "#services/Credentials.ts";
|
|
4
5
|
import {
|
|
5
|
-
blockBody,
|
|
6
6
|
type Body,
|
|
7
7
|
type ComparedRule,
|
|
8
|
+
edgeKey,
|
|
8
9
|
conflictBody,
|
|
9
10
|
contradictBody,
|
|
10
11
|
Jev,
|
|
@@ -16,15 +17,27 @@ import {
|
|
|
16
17
|
type Lines,
|
|
17
18
|
linterKey,
|
|
18
19
|
locateBody,
|
|
20
|
+
matcherKey,
|
|
21
|
+
type MatcherScores,
|
|
19
22
|
namedPairs,
|
|
20
|
-
needsBlocks,
|
|
21
23
|
type Pair,
|
|
22
24
|
pairProbability,
|
|
23
25
|
requestsOf,
|
|
24
26
|
type Rules,
|
|
27
|
+
sufficiencyKey,
|
|
25
28
|
tokensOf,
|
|
26
29
|
} from "#services/Jev.ts";
|
|
27
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
type Cause,
|
|
32
|
+
Duration,
|
|
33
|
+
Effect,
|
|
34
|
+
Layer,
|
|
35
|
+
Option,
|
|
36
|
+
Record,
|
|
37
|
+
Result,
|
|
38
|
+
Schedule,
|
|
39
|
+
Schema,
|
|
40
|
+
} from "effect";
|
|
28
41
|
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
29
42
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
30
43
|
import { RateLimiter } from "effect/unstable/persistence";
|
|
@@ -37,6 +50,16 @@ const NoulAnswers = Schema.Struct({
|
|
|
37
50
|
const ChoiceAnswers = Schema.Struct({
|
|
38
51
|
answers: Schema.Record(Schema.String, Schema.Struct({ choice: Schema.String })),
|
|
39
52
|
});
|
|
53
|
+
/** A locate request's answers: a choice per rule, and a noul per sufficiency question. */
|
|
54
|
+
const LocateAnswers = Schema.Struct({
|
|
55
|
+
answers: Schema.Record(
|
|
56
|
+
Schema.String,
|
|
57
|
+
Schema.Struct({
|
|
58
|
+
choice: Schema.optionalKey(Schema.String),
|
|
59
|
+
noul: Schema.optionalKey(Schema.Finite),
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
});
|
|
40
63
|
|
|
41
64
|
const refused = (message: string) => JevUnavailable.make({ message });
|
|
42
65
|
|
|
@@ -219,22 +242,64 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
219
242
|
return answer === undefined ? [] : [[id, answer.noul] as const];
|
|
220
243
|
}),
|
|
221
244
|
);
|
|
245
|
+
// A matcher Jev left unanswered counts as a no.
|
|
246
|
+
const scored = (
|
|
247
|
+
kind: keyof MatcherScores,
|
|
248
|
+
id: RuleId,
|
|
249
|
+
matchers: ReadonlyArray<string> = [],
|
|
250
|
+
) => matchers.map((_, index) => answers[matcherKey(kind, id, index)]?.noul ?? 0);
|
|
251
|
+
const matchers: Record<RuleId, MatcherScores> = Record.fromEntries(
|
|
252
|
+
Object.entries(rules).flatMap(([id, rule]) =>
|
|
253
|
+
rule.appliesTo === undefined && rule.excludeIf === undefined
|
|
254
|
+
? []
|
|
255
|
+
: [
|
|
256
|
+
[
|
|
257
|
+
id,
|
|
258
|
+
{
|
|
259
|
+
appliesTo: scored("appliesTo", id, rule.appliesTo),
|
|
260
|
+
excludeIf: scored("excludeIf", id, rule.excludeIf),
|
|
261
|
+
},
|
|
262
|
+
] as const,
|
|
263
|
+
],
|
|
264
|
+
),
|
|
265
|
+
);
|
|
222
266
|
return {
|
|
223
267
|
probabilities: answered(Object.keys(rules), (id) => id),
|
|
224
268
|
linter: answered(sampled, linterKey),
|
|
269
|
+
...(Record.isEmptyRecord(matchers) ? {} : { matchers }),
|
|
225
270
|
} satisfies Judged;
|
|
226
271
|
});
|
|
227
272
|
|
|
228
|
-
const chosen = (answers: Readonly<Record<string, { choice: string }>>) =>
|
|
229
|
-
Record.map(answers, (answer) => Number(answer.choice));
|
|
230
|
-
|
|
231
273
|
const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
274
|
+
const sections = sectionsOf(lines);
|
|
275
|
+
const answers = yield* answersTo(
|
|
276
|
+
"locate",
|
|
277
|
+
locateBody(config.model, lines, rules),
|
|
278
|
+
LocateAnswers,
|
|
237
279
|
);
|
|
280
|
+
// A file of one section has every finding in it. An answer that names no section,
|
|
281
|
+
// or none at all, which Jev should not give, leaves the rule unlocated; lines outside
|
|
282
|
+
// the section leave it without lines.
|
|
283
|
+
return Record.filterMap(rules, (_, id) => {
|
|
284
|
+
const chosen = sections.length === 1 ? 1 : Number(answers[id]?.choice);
|
|
285
|
+
const section = sections[chosen - 1];
|
|
286
|
+
const sufficiency = answers[sufficiencyKey(id)]?.noul;
|
|
287
|
+
if (section === undefined || sufficiency === undefined) return Result.failVoid;
|
|
288
|
+
const [start, end] = (["start", "end"] as const).map((edge) =>
|
|
289
|
+
Number(answers[edgeKey(edge, id, chosen)]?.choice),
|
|
290
|
+
);
|
|
291
|
+
const within = (line: number | undefined): line is number =>
|
|
292
|
+
line !== undefined && section.first <= line && line <= section.last;
|
|
293
|
+
return Result.succeed(
|
|
294
|
+
within(start) && within(end)
|
|
295
|
+
? {
|
|
296
|
+
section,
|
|
297
|
+
sufficiency,
|
|
298
|
+
lines: { first: Math.min(start, end), last: Math.max(start, end) },
|
|
299
|
+
}
|
|
300
|
+
: { section, sufficiency },
|
|
301
|
+
);
|
|
302
|
+
});
|
|
238
303
|
});
|
|
239
304
|
|
|
240
305
|
const conflicts = Effect.fn("Jev.conflicts")(function* (
|