@drkmttr/adhere 0.10.0 → 0.12.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 +226 -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 +85 -4
- package/src/config.ts +29 -1
- package/src/excerpt.ts +72 -62
- package/src/highlight.ts +2 -0
- package/src/init.ts +168 -13
- package/src/install.ts +147 -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/install.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, copyFile, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, sep } from "node:path";
|
|
5
|
+
import { ADHERE_DIRECTORY, type RuleId } from "#config.ts";
|
|
6
|
+
import { parseRuleMarkdown } from "#markdown.ts";
|
|
7
|
+
import { Effect } from "effect";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A GitHub repo whose `.adhere/` rules another repo copies: `org/repo`, then
|
|
11
|
+
* optionally a topic or a rule's id, then optionally `#` and a branch or tag,
|
|
12
|
+
* as in `darkmatter/standards/data/brand-ports#v3`.
|
|
13
|
+
*/
|
|
14
|
+
export interface Source {
|
|
15
|
+
readonly repo: string;
|
|
16
|
+
/** A rule id or a topic, `data/brand-ports` or `data`; empty for every rule. */
|
|
17
|
+
readonly rule: string;
|
|
18
|
+
readonly ref?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const parseSource = (spec: string): Source | undefined => {
|
|
22
|
+
const [path = "", ref] = spec.split("#");
|
|
23
|
+
const [org, repo, ...rule] = path.split("/").filter((segment) => segment.length > 0);
|
|
24
|
+
if (org === undefined || repo === undefined || ref === "") return undefined;
|
|
25
|
+
return { repo: `${org}/${repo}`, rule: rule.join("/"), ...(ref === undefined ? {} : { ref }) };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const failed = (what: string) => (cause: unknown) =>
|
|
29
|
+
new Error(`${what}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A shallow clone in a temporary directory, deleted when the scope closes.
|
|
33
|
+
* git, rather than GitHub's API, so a private repo is reached with whatever
|
|
34
|
+
* credentials git already has, and `url.<base>.insteadOf` can point
|
|
35
|
+
* github.com elsewhere, such as at SSH.
|
|
36
|
+
*/
|
|
37
|
+
const cloned = (source: Source) =>
|
|
38
|
+
Effect.acquireRelease(
|
|
39
|
+
Effect.tryPromise({
|
|
40
|
+
try: async () => {
|
|
41
|
+
const directory = await mkdtemp(join(tmpdir(), "adhere-install-"));
|
|
42
|
+
const branch = source.ref === undefined ? [] : ["--branch", source.ref];
|
|
43
|
+
const url = `https://github.com/${source.repo}.git`;
|
|
44
|
+
await new Promise<void>((resolve, reject) => {
|
|
45
|
+
let stderr = "";
|
|
46
|
+
spawn("git", ["clone", "--quiet", "--depth", "1", ...branch, url, directory], {
|
|
47
|
+
stdio: ["inherit", "ignore", "pipe"],
|
|
48
|
+
})
|
|
49
|
+
.on("error", reject)
|
|
50
|
+
.on("exit", (code) =>
|
|
51
|
+
code === 0
|
|
52
|
+
? resolve()
|
|
53
|
+
: reject(new Error(stderr.trim() || `git exited with ${code}`)),
|
|
54
|
+
)
|
|
55
|
+
.stderr?.on("data", (chunk) => {
|
|
56
|
+
stderr += chunk;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return directory;
|
|
60
|
+
},
|
|
61
|
+
catch: failed(`Could not clone ${source.repo}`),
|
|
62
|
+
}),
|
|
63
|
+
(directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** The repo's root `.adhere/` rule files by id, as lint reads them there: every `*.md` outside `cache/`. */
|
|
67
|
+
const rulesIn = (source: Source, clone: string) =>
|
|
68
|
+
Effect.tryPromise({
|
|
69
|
+
try: async () => {
|
|
70
|
+
const directory = join(clone, ADHERE_DIRECTORY);
|
|
71
|
+
const entries = await readdir(directory, { recursive: true }).catch(() => []);
|
|
72
|
+
const ids = entries
|
|
73
|
+
.filter((entry) => entry.endsWith(".md") && !entry.startsWith(`cache${sep}`))
|
|
74
|
+
.map((entry): RuleId => entry.slice(0, -".md".length).split(sep).join("/"))
|
|
75
|
+
.filter(
|
|
76
|
+
(id) => source.rule === "" || id === source.rule || id.startsWith(`${source.rule}/`),
|
|
77
|
+
)
|
|
78
|
+
.sort();
|
|
79
|
+
if (ids.length === 0) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
source.rule === ""
|
|
82
|
+
? `it has no rules in ${ADHERE_DIRECTORY}/`
|
|
83
|
+
: `it has no rule or topic ${source.rule} in ${ADHERE_DIRECTORY}/`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return ids.map((id) => ({ id, file: join(directory, `${id}.md`) }));
|
|
87
|
+
},
|
|
88
|
+
catch: failed(`Nothing to copy from ${source.repo}`),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/** Each rule a source names, with its description. */
|
|
92
|
+
export const listRules = (source: Source) =>
|
|
93
|
+
Effect.scoped(
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const rules = yield* rulesIn(source, yield* cloned(source));
|
|
96
|
+
return yield* Effect.forEach(rules, ({ id, file }) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const text = yield* Effect.tryPromise({
|
|
99
|
+
try: () => readFile(file, "utf8"),
|
|
100
|
+
catch: failed(`Could not read ${id}`),
|
|
101
|
+
});
|
|
102
|
+
const rule = yield* parseRuleMarkdown(text, `${source.repo}/${id}.md`);
|
|
103
|
+
return { id, description: rule.description };
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
export interface InstallResult {
|
|
110
|
+
readonly created: ReadonlyArray<string>;
|
|
111
|
+
readonly skipped: ReadonlyArray<string>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Copies the rules a source names into `root`'s `.adhere/` under the source's
|
|
116
|
+
* org and repo, so `data/ports` from acme/rules is the rule
|
|
117
|
+
* `acme/rules/data/ports`, and two sources' rules never collide. They are the
|
|
118
|
+
* project's own rules from then on. As with init, a file already there is
|
|
119
|
+
* skipped unless `force`.
|
|
120
|
+
*/
|
|
121
|
+
export const installRules = (root: string, source: Source, options: { readonly force?: boolean }) =>
|
|
122
|
+
Effect.scoped(
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
const rules = yield* rulesIn(source, yield* cloned(source));
|
|
125
|
+
const created: Array<string> = [];
|
|
126
|
+
const skipped: Array<string> = [];
|
|
127
|
+
for (const { id, file } of rules) {
|
|
128
|
+
const path = `${ADHERE_DIRECTORY}/${source.repo}/${id}.md`;
|
|
129
|
+
const target = join(root, path);
|
|
130
|
+
const copied = yield* Effect.tryPromise({
|
|
131
|
+
try: async () => {
|
|
132
|
+
const exists = await access(target).then(
|
|
133
|
+
() => true,
|
|
134
|
+
() => false,
|
|
135
|
+
);
|
|
136
|
+
if (exists && options.force !== true) return false;
|
|
137
|
+
await mkdir(dirname(target), { recursive: true });
|
|
138
|
+
await copyFile(file, target);
|
|
139
|
+
return true;
|
|
140
|
+
},
|
|
141
|
+
catch: failed(`Could not write ${path}`),
|
|
142
|
+
});
|
|
143
|
+
(copied ? created : skipped).push(path);
|
|
144
|
+
}
|
|
145
|
+
return { created, skipped } satisfies InstallResult;
|
|
146
|
+
}),
|
|
147
|
+
);
|
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* (
|