@drkmttr/adhere 0.10.0 → 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 +177 -41
- package/package.json +7 -7
- package/presets/effect/basics/gen-for-sequencing.md +1 -1
- package/presets/effect/data/brand-meaningful-primitives.md +1 -1
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/services/dependencies-through-layers.md +1 -1
- 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 +4 -3
- package/skills/adhere-fix/SKILL.md +10 -4
- package/src/cli.ts +9 -1
- package/src/config.ts +29 -1
- package/src/excerpt.ts +72 -62
- package/src/highlight.ts +2 -0
- package/src/markdown.ts +6 -0
- package/src/presets.ts +6 -0
- package/src/rules.ts +1 -1
- 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 +23 -7
- package/src/suppress.ts +13 -7
- package/src/version.ts +25 -1
- package/src/workflows/audit.ts +96 -26
- package/src/workflows/format.ts +51 -11
- package/presets/alchemy/data/parameterized-sql.md +0 -17
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
- package/presets/effect/basics/external-calls-are-resilient.md +0 -21
package/src/excerpt.ts
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import { scan } from "#highlight.ts";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The lines a finding shows: the statement its line is in, such as the
|
|
5
|
-
* function around it, and a few lines on either side of the line.
|
|
6
|
-
*/
|
|
3
|
+
/** The lines a finding shows: the section of the file it is in. */
|
|
7
4
|
export interface Excerpt {
|
|
8
5
|
/** The number of the first line, counting from 1. */
|
|
9
6
|
readonly start: number;
|
|
10
7
|
readonly lines: ReadonlyArray<string>;
|
|
11
8
|
}
|
|
12
9
|
|
|
13
|
-
/** How far an excerpt reaches on either side of the finding's line, in whole statements. */
|
|
14
|
-
const AROUND = 3;
|
|
15
|
-
/** The most lines a statement can have and still be shown whole. */
|
|
16
|
-
const WHOLE = 30;
|
|
17
|
-
|
|
18
10
|
/** Lines of the code, first to last, counting from 1. */
|
|
19
11
|
export interface Range {
|
|
20
12
|
readonly first: number;
|
|
@@ -48,19 +40,13 @@ const ROLES: Readonly<Record<string, Piece["role"]>> = {
|
|
|
48
40
|
",": "separator",
|
|
49
41
|
};
|
|
50
42
|
|
|
51
|
-
/**
|
|
52
|
-
* The code as pieces, without whitespace and comments, and the comments and
|
|
53
|
-
* literals that span lines: a line that starts inside one does not read as
|
|
54
|
-
* code on its own.
|
|
55
|
-
*/
|
|
43
|
+
/** The code as pieces, without whitespace and comments. */
|
|
56
44
|
const piecesOf = (code: string) => {
|
|
57
45
|
const pieces: Array<Piece> = [];
|
|
58
|
-
const spans: Array<Range> = [];
|
|
59
46
|
let line = 1;
|
|
60
47
|
let spaced = true;
|
|
61
48
|
for (const { text, kind } of scan(code)) {
|
|
62
49
|
const end = line + text.split("\n").length - 1;
|
|
63
|
-
if (kind !== undefined && end > line) spans.push({ first: line, last: end });
|
|
64
50
|
if (kind === "comment" || text.trim().length === 0) {
|
|
65
51
|
spaced = true;
|
|
66
52
|
} else if (kind !== undefined || /^[\w$]/.test(text)) {
|
|
@@ -75,7 +61,7 @@ const piecesOf = (code: string) => {
|
|
|
75
61
|
}
|
|
76
62
|
line = end;
|
|
77
63
|
}
|
|
78
|
-
return { pieces
|
|
64
|
+
return { pieces };
|
|
79
65
|
};
|
|
80
66
|
|
|
81
67
|
/**
|
|
@@ -209,6 +195,75 @@ const statementsAround = (
|
|
|
209
195
|
];
|
|
210
196
|
};
|
|
211
197
|
|
|
198
|
+
/** The most lines a section holds, unless one statement with no group to split is longer. */
|
|
199
|
+
const SECTION = 30;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The file in sections, each ending where a statement does: its top-level
|
|
203
|
+
* statements, such as its imports, constants, and functions, packed in
|
|
204
|
+
* order into sections of at most `SECTION` lines, a decorator with what it
|
|
205
|
+
* decorates. A statement longer than that splits into the statements of its
|
|
206
|
+
* largest group, such as a class's methods or a component's body, the lines
|
|
207
|
+
* around that group going with the first and last of them. Every line is in
|
|
208
|
+
* one section: the comments and blank lines above a statement go with it,
|
|
209
|
+
* and the last section runs to the end.
|
|
210
|
+
*/
|
|
211
|
+
export const sectionsOf = (lines: ReadonlyArray<string>): ReadonlyArray<Range> => {
|
|
212
|
+
const { pieces } = piecesOf(lines.join("\n"));
|
|
213
|
+
const closes = closesOf(pieces);
|
|
214
|
+
const lineOf = (index: number) => pieces[index]?.line ?? 0;
|
|
215
|
+
/** Statements as ranges of at most `SECTION` lines where they can be split. */
|
|
216
|
+
const units = (statements: ReadonlyArray<Statement>): ReadonlyArray<Range> =>
|
|
217
|
+
statements.flatMap((statement): ReadonlyArray<Range> => {
|
|
218
|
+
if (statement.last - statement.first < SECTION) return [statement];
|
|
219
|
+
const largest = statement.groups.reduce<readonly [number, number] | undefined>(
|
|
220
|
+
(best, group) =>
|
|
221
|
+
best === undefined ||
|
|
222
|
+
lineOf(group[1]) - lineOf(group[0]) > lineOf(best[1]) - lineOf(best[0])
|
|
223
|
+
? group
|
|
224
|
+
: best,
|
|
225
|
+
undefined,
|
|
226
|
+
);
|
|
227
|
+
const inner =
|
|
228
|
+
largest === undefined
|
|
229
|
+
? []
|
|
230
|
+
: units(statementsIn(pieces, closes, largest[0] + 1, largest[1]));
|
|
231
|
+
const [head, tail] = [inner[0], inner.at(-1)];
|
|
232
|
+
if (head === undefined || tail === undefined || inner.length < 2) return [statement];
|
|
233
|
+
return [
|
|
234
|
+
{ first: statement.first, last: head.last },
|
|
235
|
+
...inner.slice(1, -1),
|
|
236
|
+
{ first: tail.first, last: statement.last },
|
|
237
|
+
];
|
|
238
|
+
});
|
|
239
|
+
const sections: Array<Range> = [];
|
|
240
|
+
/** A decorator, such as `@Command({…})`, waiting for the declaration it decorates. */
|
|
241
|
+
let decorator: Range | undefined;
|
|
242
|
+
for (const found of units(statementsIn(pieces, closes, 0, pieces.length))) {
|
|
243
|
+
if ((lines[found.first - 1] ?? "").trimStart().startsWith("@")) {
|
|
244
|
+
decorator = { first: decorator?.first ?? found.first, last: found.last };
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const unit = { first: decorator?.first ?? found.first, last: found.last };
|
|
248
|
+
decorator = undefined;
|
|
249
|
+
const previous = sections.at(-1);
|
|
250
|
+
if (previous !== undefined && unit.last - previous.first < SECTION) {
|
|
251
|
+
sections[sections.length - 1] = { first: previous.first, last: unit.last };
|
|
252
|
+
} else {
|
|
253
|
+
sections.push({ first: unit.first, last: unit.last });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (decorator !== undefined) sections.push(decorator);
|
|
257
|
+
// A file of comments alone, with no statement, is one section.
|
|
258
|
+
if (sections.length === 0) return lines.length === 0 ? [] : [{ first: 1, last: lines.length }];
|
|
259
|
+
// Each section starts where the one before it ends, and the last one ends with the file.
|
|
260
|
+
return sections.flatMap((section, index): ReadonlyArray<Range> => {
|
|
261
|
+
const first = (sections[index - 1]?.last ?? 0) + 1;
|
|
262
|
+
const last = index === sections.length - 1 ? lines.length : section.last;
|
|
263
|
+
return last < first ? [] : [{ first, last }];
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
212
267
|
/**
|
|
213
268
|
* The statement that starts on `line`, such as a property, a function, or a
|
|
214
269
|
* call that opens there: the outermost statement around `line` whose first
|
|
@@ -218,48 +273,3 @@ export const statementFrom = (lines: ReadonlyArray<string>, line: number): Range
|
|
|
218
273
|
const { pieces } = piecesOf(lines.join("\n"));
|
|
219
274
|
return statementsAround(pieces, closesOf(pieces), line).find((range) => range.first === line);
|
|
220
275
|
};
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* The excerpt for a finding on `line`: the outermost statement around it
|
|
224
|
-
* with at most `WHOLE` lines, such as the function it is in, and up to
|
|
225
|
-
* `AROUND` lines on either side of `line`. A comment or template literal
|
|
226
|
-
* that spans lines counts as a statement inside the one it is in.
|
|
227
|
-
*
|
|
228
|
-
* Around `line`, the excerpt shows only whole statements, never the end of
|
|
229
|
-
* the one above or the start of the one below, so it cannot start partway
|
|
230
|
-
* through a comment, whose lines do not read as code on their own. What
|
|
231
|
-
* `line` itself is in can show in part: a long function's first line, above
|
|
232
|
-
* the statement of its body. It ends at code, since a comment at the end
|
|
233
|
-
* would be about code the excerpt leaves out.
|
|
234
|
-
*/
|
|
235
|
-
export const excerptOf = (lines: ReadonlyArray<string>, line: number): Excerpt => {
|
|
236
|
-
const { pieces, spans } = piecesOf(lines.join("\n"));
|
|
237
|
-
const closes = closesOf(pieces);
|
|
238
|
-
/** What a line is in, outermost first. */
|
|
239
|
-
const around = (at: number): ReadonlyArray<Range> => [
|
|
240
|
-
...statementsAround(pieces, closes, at),
|
|
241
|
-
...spans.filter((span) => holds(span, at)),
|
|
242
|
-
];
|
|
243
|
-
const scope = around(line).find((range) => range.last - range.first < WHOLE);
|
|
244
|
-
const top = Math.max(1, Math.min(scope?.first ?? line, line - AROUND));
|
|
245
|
-
const bottom = Math.min(lines.length, Math.max(scope?.last ?? line, line + AROUND));
|
|
246
|
-
/** What a line is in that `line` is not, which an edge shows whole or not at all. */
|
|
247
|
-
const beside = (at: number) => around(at).filter((range) => !holds(range, line));
|
|
248
|
-
let first = Math.max(
|
|
249
|
-
top,
|
|
250
|
-
...beside(top)
|
|
251
|
-
.filter((range) => range.first < top)
|
|
252
|
-
.map((range) => range.last + 1),
|
|
253
|
-
);
|
|
254
|
-
let last = Math.min(
|
|
255
|
-
bottom,
|
|
256
|
-
...beside(bottom)
|
|
257
|
-
.filter((range) => range.last > bottom)
|
|
258
|
-
.map((range) => range.first - 1),
|
|
259
|
-
);
|
|
260
|
-
const isBlank = (at: number) => (lines[at - 1] ?? "").trim().length === 0;
|
|
261
|
-
const hasCode = (at: number) => pieces.some((piece) => piece.line <= at && at <= piece.end);
|
|
262
|
-
while (first < line && isBlank(first)) first += 1;
|
|
263
|
-
while (last > line && !hasCode(last)) last -= 1;
|
|
264
|
-
return { start: first, lines: lines.slice(first - 1, last) };
|
|
265
|
-
};
|
package/src/highlight.ts
CHANGED
|
@@ -30,6 +30,8 @@ const CLASSES = ["comment", "string", "constant"] as const;
|
|
|
30
30
|
/** One group per token class, tried in order at each position. A template literal is read apart. */
|
|
31
31
|
const TOKEN = new RegExp(
|
|
32
32
|
[
|
|
33
|
+
// A URL outside a string is JSX text, as in `<a>https://example.com</a>`, and its `//` no comment.
|
|
34
|
+
/(?<url>[a-z][\w+.-]*:\/\/[^\s<>{}"'`]*)/,
|
|
33
35
|
/(?<comment>\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))/,
|
|
34
36
|
/(?<string>"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?)/,
|
|
35
37
|
/(?<constant>0[box][\da-f_]+n?|\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?n?)/,
|
package/src/markdown.ts
CHANGED
|
@@ -32,12 +32,18 @@ import { Effect, Schema } from "effect";
|
|
|
32
32
|
* so is `tests`: `only` for a rule about test files, which rules otherwise
|
|
33
33
|
* skip, and `include` for a rule that judges them too. So is `level`:
|
|
34
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"]`.
|
|
35
37
|
*/
|
|
38
|
+
const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
|
|
39
|
+
|
|
36
40
|
const FrontMatter = Schema.Struct({
|
|
37
41
|
description: Schema.String,
|
|
38
42
|
threshold: Schema.optionalKey(Schema.FiniteFromString),
|
|
39
43
|
tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
|
|
40
44
|
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
45
|
+
appliesTo: Schema.optionalKey(Matchers),
|
|
46
|
+
excludeIf: Schema.optionalKey(Matchers),
|
|
41
47
|
});
|
|
42
48
|
|
|
43
49
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
package/src/presets.ts
CHANGED
|
@@ -23,6 +23,9 @@ const presetsRoot = isStandaloneExecutable
|
|
|
23
23
|
const topics = {
|
|
24
24
|
effect: ["basics", "config", "data", "errors", "services", "testing"],
|
|
25
25
|
alchemy: ["apis", "data", "durable", "providers", "resources", "runtime", "secrets", "security"],
|
|
26
|
+
typescript: ["async", "design", "errors", "testing", "types"],
|
|
27
|
+
react: ["effects", "server", "state"],
|
|
28
|
+
security: [],
|
|
26
29
|
} as const;
|
|
27
30
|
|
|
28
31
|
type Topics = typeof topics;
|
|
@@ -57,6 +60,9 @@ export const presetOf = (name: PresetName): BuiltInPreset => {
|
|
|
57
60
|
export const presets: Readonly<Record<WholePreset, BuiltInPreset>> = {
|
|
58
61
|
effect: presetOf("effect"),
|
|
59
62
|
alchemy: presetOf("alchemy"),
|
|
63
|
+
typescript: presetOf("typescript"),
|
|
64
|
+
react: presetOf("react"),
|
|
65
|
+
security: presetOf("security"),
|
|
60
66
|
};
|
|
61
67
|
|
|
62
68
|
/** The directory each whole preset reads, for checking `topics` against it. */
|
package/src/rules.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface RuleEntry {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* A rule's id as output shows it: a preset's rule with the preset's name
|
|
30
|
-
* first, as in effect/basics/
|
|
30
|
+
* first, as in effect/basics/gen-for-sequencing, so a report that
|
|
31
31
|
* mixes presets with the project's own rules says where each came from. The
|
|
32
32
|
* project's own rules show their `.adhere/` ids as they are.
|
|
33
33
|
*/
|
|
@@ -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* (
|