@drkmttr/adhere 0.14.0 → 0.17.5

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.
Files changed (37) hide show
  1. package/README.md +294 -146
  2. package/bin/adhere.js +19 -4
  3. package/package.json +34 -12
  4. package/presets/effect/config/tests-provide-values-directly/RULE.md +12 -1
  5. package/presets/effect/data/brand-meaningful-primitives/RULE.md +14 -2
  6. package/presets/effect/data/variants-are-tagged-unions/RULE.md +17 -4
  7. package/presets/effect/services/dependencies-through-layers/RULE.md +16 -3
  8. package/presets/effect/services/no-mutable-state/RULE.md +7 -1
  9. package/skills/adhere/SKILL.md +180 -31
  10. package/skills/adhere-setup/SKILL.md +142 -16
  11. package/src/cli.ts +56 -23
  12. package/src/config.ts +49 -16
  13. package/src/index.ts +14 -0
  14. package/src/markdown.ts +9 -5
  15. package/src/providers/cloudflare.ts +84 -0
  16. package/src/providers/jev.ts +66 -0
  17. package/src/providers/openai.ts +89 -0
  18. package/src/providers/post.ts +53 -0
  19. package/src/providers/provider.ts +131 -0
  20. package/src/providers/providers.test.ts +276 -0
  21. package/src/providers/systemOne.ts +95 -0
  22. package/src/services/AdhereConfig.ts +2 -4
  23. package/src/services/Credentials.ts +101 -51
  24. package/src/services/Jev.http.ts +165 -170
  25. package/src/services/Jev.provider.test.ts +207 -0
  26. package/src/services/Jev.reads.test.ts +439 -0
  27. package/src/services/Jev.ts +205 -113
  28. package/src/services/NativeLsp.ts +289 -0
  29. package/src/services/SourceReads.test.ts +1040 -0
  30. package/src/services/SourceReads.ts +437 -0
  31. package/src/services/SourceWalker.ts +26 -59
  32. package/src/workflows/audit.ts +440 -265
  33. package/src/workflows/format.ts +74 -17
  34. package/src/workspaces.test.ts +73 -0
  35. package/src/workspaces.ts +91 -0
  36. package/presets/effect/services/operations-have-no-requirements/RULE.md +0 -50
  37. package/presets/effect/services/test-layers-are-in-memory/RULE.md +0 -29
@@ -1,5 +1,14 @@
1
- import type { AppendState, JevState, JudgedFile, RuleId } from "#config.ts";
1
+ import type { AppendState, JevState, JudgedFile, ReadState, RuleId } from "#config.ts";
2
2
  import { sectionsOf } from "#excerpt.ts";
3
+ import { jev } from "#providers/jev.ts";
4
+ import {
5
+ Answered,
6
+ type Provider,
7
+ type Question,
8
+ RequestFailed,
9
+ type State,
10
+ } from "#providers/provider.ts";
11
+ import { questionsOf, type SystemOneQuestion } from "#providers/systemOne.ts";
3
12
  import { AdhereConfig } from "#services/AdhereConfig.ts";
4
13
  import { Credentials } from "#services/Credentials.ts";
5
14
  import {
@@ -29,60 +38,47 @@ import {
29
38
  tokensOf,
30
39
  } from "#services/Jev.ts";
31
40
  import {
32
- type Cause,
33
41
  Duration,
34
42
  Effect,
35
43
  Layer,
36
- Option,
44
+ Predicate,
37
45
  Record,
46
+ Redacted,
38
47
  Result,
39
48
  Schedule,
40
49
  Schema,
41
50
  } from "effect";
42
- import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";
43
- import * as HttpClientRequest from "effect/http/HttpClientRequest";
51
+ import { FetchHttpClient } from "effect/http";
44
52
  import { RateLimiter } from "effect/persistence";
45
53
 
46
- const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
47
-
48
- const NoulAnswers = Schema.Struct({
49
- answers: Schema.Record(Schema.String, Schema.Struct({ noul: Schema.Finite })),
54
+ /** A provider's answers to `noul`s, which the eval's harness reads too, to `choice`s, and to a locate request's both. */
55
+ export const NoulAnswers = Schema.Struct({
56
+ ...Answered.fields,
57
+ answers: Schema.Record(Schema.String, Schema.Finite),
50
58
  });
51
59
  const ChoiceAnswers = Schema.Struct({
52
- answers: Schema.Record(Schema.String, Schema.Struct({ choice: Schema.String })),
53
- });
54
- /** A locate request's answers: a choice per rule, and a noul per sufficiency question. */
55
- const LocateAnswers = Schema.Struct({
56
- answers: Schema.Record(
57
- Schema.String,
58
- Schema.Struct({
59
- choice: Schema.optionalKey(Schema.String),
60
- noul: Schema.optionalKey(Schema.Finite),
61
- }),
62
- ),
60
+ ...Answered.fields,
61
+ answers: Schema.Record(Schema.String, Schema.String),
63
62
  });
63
+ const LocateAnswers = Answered;
64
64
 
65
65
  const refused = (message: string) => JevUnavailable.make({ message });
66
66
 
67
+ const messageOf = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause));
68
+
67
69
  /** Bun's API, for a rule's hook. Read off `globalThis`, as presets.ts reads it: Vitest runs this on Node. */
68
70
  const bun = (globalThis as { readonly Bun?: unknown }).Bun as Parameters<AppendState>[2];
69
71
 
70
72
  /**
71
- * A body as it goes out: when its rule's `reads` names the workspace's
72
- * packages, with them in its state, and when it has `appendState`, with what
73
- * the hook returns spread over that. A hook that fails refuses the run.
73
+ * A prepared body as it goes out: `appendState` sees the shared enriched
74
+ * state, with what it returns spread over this group's state alone.
75
+ * A hook that fails refuses the run.
74
76
  */
75
77
  const hooked = (
76
- sent: Body & { readonly state: JevState },
78
+ body: Body<SystemOneQuestion> & { readonly state: JevState },
77
79
  group: Rules,
78
80
  file: JudgedFile,
79
- workspacePackages: Readonly<Record<string, string>>,
80
- ): Effect.Effect<Body, JevUnavailable> => {
81
- const body = Object.values(group).some(
82
- (rule) => rule.reads?.includes("workspacePackages") === true,
83
- )
84
- ? { ...sent, state: { ...sent.state, workspacePackages } }
85
- : sent;
81
+ ): Effect.Effect<Body<SystemOneQuestion>, JevUnavailable> => {
86
82
  const [hook] = Object.entries(group).flatMap(([id, rule]) =>
87
83
  rule.appendState === undefined ? [] : [[id, rule.appendState] as const],
88
84
  );
@@ -93,69 +89,78 @@ const hooked = (
93
89
  ...body,
94
90
  state: { ...body.state, ...(await appendState(body.state, file, bun)) },
95
91
  }),
96
- catch: (cause) =>
97
- refused(
98
- `the appendState of ${id} failed: ${cause instanceof Error ? cause.message : String(cause)}`,
99
- ),
92
+ catch: (cause) => refused(`the appendState of ${id} failed: ${messageOf(cause)}`),
100
93
  });
101
94
  };
102
95
 
103
- /** A failed attempt, for the log: the status and Ray ID when Jev's side answered, the cause otherwise. */
104
- const attemptFailure = (
105
- problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
106
- ): string =>
107
- HttpClientError.isHttpClientError(problem) && problem.reason._tag === "StatusCodeError"
108
- ? `HTTP ${problem.reason.response.status}, Ray ID ${problem.reason.response.headers["cf-ray"] ?? "none"}`
109
- : problem.message;
110
-
111
- /** Jev's error body, on one line and cut short, or nothing when it sent none. */
112
- const detailOf = (body: string): string => {
113
- const text = body.trim().replace(/\s+/g, " ");
114
- return text.length === 0 ? "" : `: ${text.slice(0, 300)}`;
96
+ /**
97
+ * A provider's rejection, as the run takes it: a `ContextOverflow` skips the
98
+ * file, a `RequestBlocked` reports it blocked, and anything else refuses the
99
+ * run with what the provider said. Read by tag, not class, so a provider
100
+ * built against another copy of adhere is read the same.
101
+ */
102
+ const rejected = (cause: unknown): JevOverflow | JevBlocked | JevUnavailable => {
103
+ if (Predicate.isTagged(cause, "ContextOverflow")) return JevOverflow.make();
104
+ if (Predicate.isTagged(cause, "RequestBlocked") && Predicate.hasProperty(cause, "reference")) {
105
+ return JevBlocked.make({ ray: String(cause.reference) });
106
+ }
107
+ return refused(messageOf(cause));
115
108
  };
116
109
 
117
- /** What Jev answers, with a 400, to a request over its context. */
118
- const Overflowed = Schema.fromJsonString(
119
- Schema.Struct({ detail: Schema.Struct({ error_type: Schema.Literal("max_tokens_exceeded") }) }),
120
- );
121
- const isOverflow = (body: string): boolean =>
122
- Option.isSome(Schema.decodeUnknownOption(Overflowed)(body));
123
-
124
- /** A firewall's block page: HTML where Jev answers JSON, even for its own errors. */
125
- const isBlockPage = (response: HttpClientResponse.HttpClientResponse, body: string): boolean =>
126
- (response.headers["content-type"] ?? "").includes("text/html") ||
127
- body.trimStart().startsWith("<");
110
+ /** Whether a failed attempt is worth another: a `RequestFailed`, read by tag, with no answer, a 408, a 429, or a 5xx. */
111
+ const isTransient = (cause: unknown): boolean => {
112
+ if (!Predicate.isTagged(cause, "RequestFailed") || !Predicate.hasProperty(cause, "status")) {
113
+ return false;
114
+ }
115
+ const { status } = cause;
116
+ return (
117
+ status === 0 ||
118
+ status === 408 ||
119
+ status === 429 ||
120
+ (Predicate.isNumber(status) && status >= 500)
121
+ );
122
+ };
128
123
 
129
124
  /**
130
- * A request that failed. A 400 saying the request is over Jev's context is a
131
- * `JevOverflow`, which skips the file. Any other answer outside 2xx refuses
132
- * the run with its status and what Jev said, since the status alone does not
133
- * say why, and a request that got no answer refuses with the cause.
125
+ * One request to `provider`, as adhere asks every provider: each attempt
126
+ * after `pace`, given 30 seconds before its signal aborts it, and logged when
127
+ * it fails. One that failed transiently is tried again up to three times,
128
+ * after half a second, one, and two. What it answers decodes as `Answers`.
129
+ * The eval's harness asks its providers this way too.
134
130
  */
135
- const failed = (
136
- problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
137
- ) => {
138
- if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
139
- return Effect.fail(refused(`System One request failed: ${problem.message}`));
140
- }
141
- const { response } = problem.reason;
142
- return Effect.flatMap(
143
- Effect.orElseSucceed(response.text, () => "").pipe(
144
- Effect.tap((body) =>
145
- Effect.logDebug(
146
- `Jev answered HTTP ${response.status}, ${response.headers["content-type"] ?? "no content type"}, ${body.length} characters, Ray ID ${response.headers["cf-ray"] ?? "none"}`,
131
+ export const askProvider = <A>(
132
+ provider: Provider,
133
+ state: State,
134
+ questions: ReadonlyArray<Question>,
135
+ Answers: Schema.Codec<A, unknown, never, never>,
136
+ apiKey?: string,
137
+ pace: Effect.Effect<unknown, unknown> = Effect.void,
138
+ ): Effect.Effect<A, JevOverflow | JevBlocked | JevUnavailable> =>
139
+ pace.pipe(
140
+ Effect.andThen(
141
+ Effect.tryPromise({
142
+ try: (signal) =>
143
+ provider.ask(state, questions, apiKey === undefined ? { signal } : { apiKey, signal }),
144
+ catch: (cause) => cause,
145
+ }).pipe(
146
+ Effect.timeoutOrElse({
147
+ duration: "30 seconds",
148
+ orElse: () =>
149
+ Effect.fail(new RequestFailed(`${provider.model} did not answer in 30 seconds`, 0)),
150
+ }),
151
+ ),
152
+ ),
153
+ Effect.tapError((cause) => Effect.logDebug(`attempt failed: ${messageOf(cause)}`)),
154
+ Effect.retry({ while: isTransient, times: 3, schedule: Schedule.exponential("500 millis") }),
155
+ Effect.mapError(rejected),
156
+ Effect.flatMap((answered) =>
157
+ Schema.decodeUnknownEffect(Answers)(answered).pipe(
158
+ Effect.mapError((problem) =>
159
+ refused(`${provider.model}'s answers did not decode: ${problem.message}`),
147
160
  ),
148
161
  ),
149
- Effect.tap((body) => Effect.logTrace(`Jev's answer: ${body.slice(0, 4000)}`)),
150
162
  ),
151
- (body): Effect.Effect<never, JevOverflow | JevBlocked | JevUnavailable> =>
152
- response.status === 400 && isOverflow(body)
153
- ? Effect.fail(JevOverflow.make())
154
- : response.status === 403 && isBlockPage(response, body)
155
- ? Effect.fail(JevBlocked.make({ ray: response.headers["cf-ray"] ?? "none given" }))
156
- : Effect.fail(refused(`Jev answered HTTP ${response.status}${detailOf(body)}`)),
157
163
  );
158
- };
159
164
 
160
165
  /**
161
166
  * Comparing rules has no file to skip, so a request over Jev's context, or one
@@ -181,70 +186,59 @@ export const JevLive = Layer.effect(Jev)(
181
186
  Effect.gen(function* () {
182
187
  const config = yield* AdhereConfig;
183
188
  const credentials = yield* Credentials;
184
- const http = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
185
- // `--rpm`: one request per 60/rpm seconds, evenly spaced rather than in a
186
- // burst each minute. Before the retries, so a retry waits its turn too.
187
- type Failure = HttpClientError.HttpClientError | RateLimiter.RateLimiterError;
188
- const paced: HttpClient.HttpClient.With<Failure> =
189
- config.rpm === undefined
190
- ? // The same client, typed to fail the ways the throttled one can.
191
- HttpClient.transformResponse(
192
- http,
193
- (response): Effect.Effect<HttpClientResponse.HttpClientResponse, Failure> => response,
194
- )
195
- : http.pipe(
196
- HttpClient.withRateLimiter({
197
- limiter: yield* RateLimiter.RateLimiter,
198
- key: "jev",
199
- algorithm: "token-bucket",
200
- limit: 1,
201
- window: Duration.millis(60_000 / config.rpm),
202
- }),
189
+ const fetch = yield* FetchHttpClient.Fetch;
190
+ const limiter = yield* RateLimiter.RateLimiter;
191
+
192
+ const provider: Provider = config.provider ?? jev({ model: config.model, fetch });
193
+ // The key it asks with, when it names one `adhere login` saves. Read when
194
+ // a request needs it, not in the layer: a run with nothing pending needs none.
195
+ const apiKey: Effect.Effect<string | undefined, JevUnavailable> =
196
+ provider.savedKey === undefined
197
+ ? Effect.succeed(undefined)
198
+ : yield* Effect.cached(
199
+ credentials.key(provider.savedKey).pipe(
200
+ Effect.map(Redacted.value),
201
+ Effect.mapError((problem) => refused(problem.message)),
202
+ ),
203
203
  );
204
- const client = paced.pipe(
205
- HttpClient.transformResponse(Effect.timeout("30 seconds")),
206
- // Every failed attempt, including those retried, so a log shows what retrying hid.
207
- HttpClient.transformResponse(
208
- Effect.tapError((problem) => Effect.logDebug(`attempt failed: ${attemptFailure(problem)}`)),
209
- ),
210
- HttpClient.retryTransient({
211
- schedule: Schedule.exponential("500 millis"),
212
- times: 3,
213
- }),
214
- );
215
204
 
216
- /** One request. `kind` names the question for the log: judge, locate, blocks, and so on. */
217
- const ask = <A>(kind: string, body: Body, Answers: Schema.Codec<A, unknown, never, never>) =>
205
+ // `--rpm`: one attempt per 60/rpm seconds, evenly spaced rather than in a
206
+ // burst each minute, so a retry waits its turn too.
207
+ const pace =
208
+ config.rpm === undefined
209
+ ? Effect.void
210
+ : RateLimiter.sleep(limiter, {
211
+ key: "provider",
212
+ algorithm: "token-bucket",
213
+ limit: 1,
214
+ window: Duration.millis(60_000 / config.rpm),
215
+ });
216
+
217
+ /** One request. `kind` names the question for the log: judge, locate, conflicts, and so on. */
218
+ const ask = <A>(
219
+ kind: string,
220
+ body: Body<SystemOneQuestion>,
221
+ Answers: Schema.Codec<A, unknown, never, never>,
222
+ ) =>
218
223
  Effect.gen(function* () {
219
- // Read here, not in the layer: a run with nothing pending needs no key.
220
- const apiKey = yield* credentials.apiKey.pipe(
221
- Effect.mapError((problem) => refused(problem.message)),
222
- );
223
- const request = yield* HttpClientRequest.bodyJson(
224
- HttpClientRequest.post(SYSTEM_ONE),
225
- body,
226
- ).pipe(Effect.orDie);
224
+ const key = yield* apiKey;
227
225
  const questions = Object.keys(body.questions).length;
228
226
  yield* Effect.logDebug(
229
- `${kind}: sending ${questions} ${questions === 1 ? "question" : "questions"}, about ${tokensOf(body)} tokens`,
227
+ `${kind}: asking ${provider.model} ${questions} ${questions === 1 ? "question" : "questions"}, about ${tokensOf(body)} tokens`,
230
228
  );
231
- const [elapsed, response] = yield* Effect.timed(
232
- client.execute(HttpClientRequest.bearerToken(request, apiKey)).pipe(Effect.catch(failed)),
229
+ const [elapsed, answered] = yield* Effect.timed(
230
+ askProvider(provider, body.state, questionsOf(body.questions), Answers, key, pace),
233
231
  );
234
232
  yield* Effect.logDebug(
235
- `${kind}: HTTP ${response.status} in ${Math.round(Duration.toMillis(elapsed))} ms, Ray ID ${response.headers["cf-ray"] ?? "none"}`,
236
- );
237
- return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
238
- Effect.mapError((problem) =>
239
- refused(`System One response did not decode: ${problem.message}`),
240
- ),
233
+ `${kind}: ${provider.model} answered in ${Math.round(Duration.toMillis(elapsed))} ms`,
241
234
  );
235
+ return answered;
242
236
  });
243
237
 
244
238
  /** Every answer to a body, over as many requests as Jev's context needs. */
245
239
  const answersTo = <A>(
246
240
  kind: string,
247
- body: Body,
241
+ body: Body<SystemOneQuestion>,
248
242
  Answers: Schema.Codec<
249
243
  { readonly answers: Readonly<Record<string, A>> },
250
244
  unknown,
@@ -269,7 +263,7 @@ export const JevLive = Layer.effect(Jev)(
269
263
  const answersAbout = <A>(
270
264
  kind: string,
271
265
  rules: Rules,
272
- bodyOf: (group: Rules) => Body & { readonly state: JevState },
266
+ bodyOf: (group: Rules) => Body<SystemOneQuestion> & { readonly state: JevState },
273
267
  file: JudgedFile,
274
268
  Answers: Schema.Codec<
275
269
  { readonly answers: Readonly<Record<string, A>> },
@@ -280,9 +274,8 @@ export const JevLive = Layer.effect(Jev)(
280
274
  ) =>
281
275
  Effect.map(
282
276
  Effect.forEach(requestGroups(rules), (group) =>
283
- Effect.flatMap(
284
- hooked(bodyOf(group), group, file, config.workspacePackages ?? {}),
285
- (body) => answersTo(kind, body, Answers),
277
+ Effect.flatMap(hooked(bodyOf(group), group, file), (body) =>
278
+ answersTo(kind, body, Answers),
286
279
  ),
287
280
  ),
288
281
  (groups) =>
@@ -294,46 +287,47 @@ export const JevLive = Layer.effect(Jev)(
294
287
  rules: Rules,
295
288
  sampled: ReadonlyArray<RuleId>,
296
289
  file: JudgedFile,
290
+ readState: ReadState = {},
297
291
  ) {
298
292
  const answers = yield* answersAbout(
299
293
  "judge",
300
294
  rules,
301
- (group) => judgeBody(config.model, lines, group, sampled),
295
+ (group) => judgeBody(config.model, lines, group, sampled, readState),
302
296
  file,
303
297
  NoulAnswers,
304
298
  );
305
- /** Each rule's answer, read from the question it rode under. */
306
- const answered = (ids: ReadonlyArray<RuleId>, keyOf: (id: RuleId) => string) =>
307
- Record.fromEntries(
308
- ids.flatMap((id) => {
309
- const answer = answers[keyOf(id)];
310
- return answer === undefined ? [] : [[id, answer.noul] as const];
311
- }),
312
- );
313
- // A matcher Jev left unanswered counts as a no.
314
- const scored = (
315
- kind: keyof MatcherScores,
316
- id: RuleId,
317
- matchers: ReadonlyArray<string> = [],
318
- ) => matchers.map((_, index) => answers[matcherKey(kind, id, index)]?.noul ?? 0);
299
+ const all = (scores: ReadonlyArray<number | undefined>): scores is ReadonlyArray<number> =>
300
+ scores.every((score) => score !== undefined);
301
+ // A rule is answered when its question and each of its matchers' are: a
302
+ // matcher left unanswered, as a refusal leaves one, leaves its rule
303
+ // unanswered too, to ask again on the next run rather than cache as a no.
304
+ const judged = Object.entries(rules).flatMap(([id, rule]) => {
305
+ const scored = (kind: keyof MatcherScores) =>
306
+ (rule[kind] ?? []).map((_, index) => answers[matcherKey(kind, id, index)]);
307
+ const probability = answers[id];
308
+ const appliesTo = scored("appliesTo");
309
+ const excludeIf = scored("excludeIf");
310
+ return probability === undefined || !all(appliesTo) || !all(excludeIf)
311
+ ? []
312
+ : [{ id, rule, probability, appliesTo, excludeIf }];
313
+ });
319
314
  const matchers: Record<RuleId, MatcherScores> = Record.fromEntries(
320
- Object.entries(rules).flatMap(([id, rule]) =>
315
+ judged.flatMap(({ id, rule, appliesTo, excludeIf }) =>
321
316
  rule.appliesTo === undefined && rule.excludeIf === undefined
322
317
  ? []
323
- : [
324
- [
325
- id,
326
- {
327
- appliesTo: scored("appliesTo", id, rule.appliesTo),
328
- excludeIf: scored("excludeIf", id, rule.excludeIf),
329
- },
330
- ] as const,
331
- ],
318
+ : [[id, { appliesTo, excludeIf }] as const],
332
319
  ),
333
320
  );
334
321
  return {
335
- probabilities: answered(Object.keys(rules), (id) => id),
336
- linter: answered(sampled, linterKey),
322
+ probabilities: Record.fromEntries(
323
+ judged.map(({ id, probability }) => [id, probability] as const),
324
+ ),
325
+ linter: Record.fromEntries(
326
+ sampled.flatMap((id) => {
327
+ const answer = answers[linterKey(id)];
328
+ return answer === undefined ? [] : [[id, answer] as const];
329
+ }),
330
+ ),
337
331
  ...(Record.isEmptyRecord(matchers) ? {} : { matchers }),
338
332
  } satisfies Judged;
339
333
  });
@@ -342,25 +336,26 @@ export const JevLive = Layer.effect(Jev)(
342
336
  lines: Lines,
343
337
  rules: Rules,
344
338
  file: JudgedFile,
339
+ readState: ReadState = {},
345
340
  ) {
346
341
  const sections = sectionsOf(lines);
347
342
  const answers = yield* answersAbout(
348
343
  "locate",
349
344
  rules,
350
- (group) => locateBody(config.model, lines, group),
345
+ (group) => locateBody(config.model, lines, group, readState),
351
346
  file,
352
347
  LocateAnswers,
353
348
  );
354
349
  // A file of one section has every finding in it. An answer that names no section,
355
- // or none at all, which Jev should not give, leaves the rule unlocated; lines outside
356
- // the section leave it without lines.
350
+ // or none at all, which a model should not give, leaves the rule unlocated; lines
351
+ // outside the section leave it without lines.
357
352
  return Record.filterMap(rules, (_, id) => {
358
- const chosen = sections.length === 1 ? 1 : Number(answers[id]?.choice);
353
+ const chosen = sections.length === 1 ? 1 : Number(answers[id]);
359
354
  const section = sections[chosen - 1];
360
- const sufficiency = answers[sufficiencyKey(id)]?.noul;
361
- if (section === undefined || sufficiency === undefined) return Result.failVoid;
355
+ const sufficiency = answers[sufficiencyKey(id)];
356
+ if (section === undefined || typeof sufficiency !== "number") return Result.failVoid;
362
357
  const [start, end] = (["start", "end"] as const).map((edge) =>
363
- Number(answers[edgeKey(edge, id, chosen)]?.choice),
358
+ Number(answers[edgeKey(edge, id, chosen)]),
364
359
  );
365
360
  const within = (line: number | undefined): line is number =>
366
361
  line !== undefined && section.first <= line && line <= section.last;