@drkmttr/adhere 0.11.0 → 0.13.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 +184 -37
- package/package.json +9 -9
- package/presets/effect/basics/{instrument-with-pipe.md → instrument-with-pipe/RULE.md} +1 -1
- package/presets/effect/config/{business-logic-depends-on-config-service.md → business-logic-depends-on-config-service/RULE.md} +3 -3
- package/presets/effect/config/{secrets-are-redacted.md → secrets-are-redacted/RULE.md} +3 -3
- package/presets/effect/config/{validate-with-schema.md → validate-with-schema/RULE.md} +4 -4
- package/skills/adhere/SKILL.md +206 -118
- package/skills/adhere-fix/SKILL.md +9 -8
- package/skills/adhere-setup/SKILL.md +244 -0
- package/src/cli.ts +114 -25
- package/src/config.ts +50 -3
- package/src/index.ts +2 -0
- package/src/init.ts +193 -27
- package/src/install.ts +158 -0
- package/src/main.ts +1 -1
- package/src/rules.ts +135 -56
- package/src/services/AdhereConfig.ts +2 -2
- package/src/services/Credentials.ts +4 -4
- package/src/services/Jev.http.ts +76 -10
- package/src/services/Jev.ts +39 -10
- package/src/workflows/audit.ts +23 -12
- package/src/workflows/format.ts +172 -35
- /package/presets/alchemy/apis/{match-remote-errors-by-tag.md → match-remote-errors-by-tag/RULE.md} +0 -0
- /package/presets/alchemy/apis/{schemas-at-trust-boundaries.md → schemas-at-trust-boundaries/RULE.md} +0 -0
- /package/presets/alchemy/data/{commit-migrations-then-deploy.md → commit-migrations-then-deploy/RULE.md} +0 -0
- /package/presets/alchemy/data/{hyperdrive-direct-origin.md → hyperdrive-direct-origin/RULE.md} +0 -0
- /package/presets/alchemy/data/{migrations-use-direct-url.md → migrations-use-direct-url/RULE.md} +0 -0
- /package/presets/alchemy/data/{never-disable-tls-verification.md → never-disable-tls-verification/RULE.md} +0 -0
- /package/presets/alchemy/data/{private-endpoint-at-runtime.md → private-endpoint-at-runtime/RULE.md} +0 -0
- /package/presets/alchemy/data/{single-migration-owner.md → single-migration-owner/RULE.md} +0 -0
- /package/presets/alchemy/durable/{idempotent-event-handlers.md → idempotent-event-handlers/RULE.md} +0 -0
- /package/presets/alchemy/durable/{io-inside-steps.md → io-inside-steps/RULE.md} +0 -0
- /package/presets/alchemy/durable/{no-self-triggering-bucket-writes.md → no-self-triggering-bucket-writes/RULE.md} +0 -0
- /package/presets/alchemy/durable/{stable-step-names.md → stable-step-names/RULE.md} +0 -0
- /package/presets/alchemy/durable/{webhook-state-is-truth.md → webhook-state-is-truth/RULE.md} +0 -0
- /package/presets/alchemy/providers/{diff-guards-unresolved-props.md → diff-guards-unresolved-props/RULE.md} +0 -0
- /package/presets/alchemy/providers/{idempotent-delete.md → idempotent-delete/RULE.md} +0 -0
- /package/presets/alchemy/providers/{lazy-layer-construction.md → lazy-layer-construction/RULE.md} +0 -0
- /package/presets/alchemy/providers/{reconcile-from-observed-state.md → reconcile-from-observed-state/RULE.md} +0 -0
- /package/presets/alchemy/resources/{anchor-paths-with-import-meta.md → anchor-paths-with-import-meta/RULE.md} +0 -0
- /package/presets/alchemy/resources/{idempotent-actions-and-seeds.md → idempotent-actions-and-seeds/RULE.md} +0 -0
- /package/presets/alchemy/resources/{previews-reference-shared-resources.md → previews-reference-shared-resources/RULE.md} +0 -0
- /package/presets/alchemy/resources/{protect-irreplaceable-data.md → protect-irreplaceable-data/RULE.md} +0 -0
- /package/presets/alchemy/resources/{remote-state-for-ci.md → remote-state-for-ci/RULE.md} +0 -0
- /package/presets/alchemy/resources/{stable-logical-ids.md → stable-logical-ids/RULE.md} +0 -0
- /package/presets/alchemy/resources/{stage-unique-physical-names.md → stage-unique-physical-names/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{bind-tag-not-implementation.md → bind-tag-not-implementation/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{deterministic-config-defaults.md → deterministic-config-defaults/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{disposables-in-request-scope.md → disposables-in-request-scope/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{lambda-no-post-response-work.md → lambda-no-post-response-work/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{no-per-request-work-at-construction.md → no-per-request-work-at-construction/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{resolve-config-and-bindings-at-construction.md → resolve-config-and-bindings-at-construction/RULE.md} +0 -0
- /package/presets/alchemy/secrets/{let-alchemy-mint-infra-secrets.md → let-alchemy-mint-infra-secrets/RULE.md} +0 -0
- /package/presets/alchemy/secrets/{none-in-browser-env.md → none-in-browser-env/RULE.md} +0 -0
- /package/presets/alchemy/secrets/{redacted-until-use.md → redacted-until-use/RULE.md} +0 -0
- /package/presets/alchemy/secrets/{unwrap-before-compare-or-send.md → unwrap-before-compare-or-send/RULE.md} +0 -0
- /package/presets/alchemy/security/{authorize-public-endpoints.md → authorize-public-endpoints/RULE.md} +0 -0
- /package/presets/alchemy/security/{github-secret-not-variable.md → github-secret-not-variable/RULE.md} +0 -0
- /package/presets/alchemy/security/{identity-from-verified-session.md → identity-from-verified-session/RULE.md} +0 -0
- /package/presets/alchemy/security/{keep-auth-protections-on.md → keep-auth-protections-on/RULE.md} +0 -0
- /package/presets/alchemy/security/{least-privilege-bindings.md → least-privilege-bindings/RULE.md} +0 -0
- /package/presets/alchemy/security/{least-privilege-ci-credentials.md → least-privilege-ci-credentials/RULE.md} +0 -0
- /package/presets/alchemy/security/{lookup-failure-is-not-anonymous.md → lookup-failure-is-not-anonymous/RULE.md} +0 -0
- /package/presets/effect/basics/{gen-for-sequencing.md → gen-for-sequencing/RULE.md} +0 -0
- /package/presets/effect/config/{tests-provide-values-directly.md → tests-provide-values-directly/RULE.md} +0 -0
- /package/presets/effect/data/{brand-meaningful-primitives.md → brand-meaningful-primitives/RULE.md} +0 -0
- /package/presets/effect/data/{variants-are-tagged-unions.md → variants-are-tagged-unions/RULE.md} +0 -0
- /package/presets/effect/errors/{catch-defects-at-boundaries-only.md → catch-defects-at-boundaries-only/RULE.md} +0 -0
- /package/presets/effect/errors/{defects-for-bugs.md → defects-for-bugs/RULE.md} +0 -0
- /package/presets/effect/errors/{wrap-external-errors.md → wrap-external-errors/RULE.md} +0 -0
- /package/presets/effect/services/{dependencies-through-layers.md → dependencies-through-layers/RULE.md} +0 -0
- /package/presets/effect/services/{fresh-layer-per-test.md → fresh-layer-per-test/RULE.md} +0 -0
- /package/presets/effect/services/{memoize-parameterized-layers.md → memoize-parameterized-layers/RULE.md} +0 -0
- /package/presets/effect/services/{no-mutable-state.md → no-mutable-state/RULE.md} +0 -0
- /package/presets/effect/services/{operations-have-no-requirements.md → operations-have-no-requirements/RULE.md} +0 -0
- /package/presets/effect/services/{test-layers-are-in-memory.md → test-layers-are-in-memory/RULE.md} +0 -0
- /package/presets/effect/testing/{test-clock-for-time.md → test-clock-for-time/RULE.md} +0 -0
- /package/presets/react/effects/{clean-up-what-they-start.md → clean-up-what-they-start/RULE.md} +0 -0
- /package/presets/react/effects/{event-logic-in-handlers.md → event-logic-in-handlers/RULE.md} +0 -0
- /package/presets/react/effects/{external-stores-use-sync-external-store.md → external-stores-use-sync-external-store/RULE.md} +0 -0
- /package/presets/react/effects/{fetches-ignore-stale-responses.md → fetches-ignore-stale-responses/RULE.md} +0 -0
- /package/presets/react/effects/{notify-parents-in-handlers.md → notify-parents-in-handlers/RULE.md} +0 -0
- /package/presets/react/server/{no-private-data-to-client.md → no-private-data-to-client/RULE.md} +0 -0
- /package/presets/react/server/{server-functions-authorize-and-validate.md → server-functions-authorize-and-validate/RULE.md} +0 -0
- /package/presets/react/state/{no-contradictory-state.md → no-contradictory-state/RULE.md} +0 -0
- /package/presets/react/state/{no-props-copied-into-state.md → no-props-copied-into-state/RULE.md} +0 -0
- /package/presets/react/state/{store-ids-not-copies.md → store-ids-not-copies/RULE.md} +0 -0
- /package/presets/security/{constant-time-secret-compare.md → constant-time-secret-compare/RULE.md} +0 -0
- /package/presets/security/{no-secrets-in-output.md → no-secrets-in-output/RULE.md} +0 -0
- /package/presets/security/{no-untrusted-input-in-commands-or-paths.md → no-untrusted-input-in-commands-or-paths/RULE.md} +0 -0
- /package/presets/security/{parameterized-queries.md → parameterized-queries/RULE.md} +0 -0
- /package/presets/typescript/async/{concurrent-independent-work.md → concurrent-independent-work/RULE.md} +0 -0
- /package/presets/typescript/async/{network-calls-have-timeouts.md → network-calls-have-timeouts/RULE.md} +0 -0
- /package/presets/typescript/design/{no-argument-mutation.md → no-argument-mutation/RULE.md} +0 -0
- /package/presets/typescript/errors/{no-swallowed-errors.md → no-swallowed-errors/RULE.md} +0 -0
- /package/presets/typescript/errors/{release-on-every-path.md → release-on-every-path/RULE.md} +0 -0
- /package/presets/typescript/testing/{assert-outcomes.md → assert-outcomes/RULE.md} +0 -0
- /package/presets/typescript/testing/{independent-tests.md → independent-tests/RULE.md} +0 -0
- /package/presets/typescript/types/{assertions-only-when-proven.md → assertions-only-when-proven/RULE.md} +0 -0
- /package/presets/typescript/types/{derive-dont-restate.md → derive-dont-restate/RULE.md} +0 -0
- /package/presets/typescript/types/{unrepresentable-invalid-states.md → unrepresentable-invalid-states/RULE.md} +0 -0
- /package/presets/typescript/types/{validate-external-data.md → validate-external-data/RULE.md} +0 -0
package/src/rules.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ConfigUnavailable,
|
|
3
3
|
type Override,
|
|
4
|
-
|
|
4
|
+
Rule,
|
|
5
5
|
type RuleId,
|
|
6
6
|
type RuleSource,
|
|
7
7
|
SKIPPED_DIRECTORIES,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { parseRuleMarkdown } from "#markdown.ts";
|
|
10
10
|
import { walkFiles } from "#walk.ts";
|
|
11
11
|
import { clearingStatus, countOf, Status } from "#services/Status.ts";
|
|
12
|
-
import { Effect, FileSystem, Path, Record } from "effect";
|
|
12
|
+
import { Effect, FileSystem, Path, Record, Schema } from "effect";
|
|
13
13
|
|
|
14
14
|
const refused = (directory: string) => (problem: { readonly message: string }) =>
|
|
15
15
|
ConfigUnavailable.make({
|
|
@@ -40,6 +40,10 @@ export type RuleSet = ReadonlyArray<RuleEntry>;
|
|
|
40
40
|
|
|
41
41
|
const ADHERE_SEGMENT = ".adhere";
|
|
42
42
|
const CACHE_SEGMENT = "cache";
|
|
43
|
+
/** Where in a `.adhere/` its rules sit, one to a directory. */
|
|
44
|
+
const RULES_PREFIX = "rules/";
|
|
45
|
+
/** The file in a rule's directory that is the rule: Markdown, or TypeScript that default-exports `defineRule({...})`. */
|
|
46
|
+
const RULE_FILES: ReadonlySet<string> = new Set(["RULE.md", "RULE.ts"]);
|
|
43
47
|
|
|
44
48
|
const normalized = (path: string): string => path.replaceAll("\\", "/");
|
|
45
49
|
|
|
@@ -48,12 +52,69 @@ const segmentsOf = (relative: string): ReadonlyArray<string> =>
|
|
|
48
52
|
.split("/")
|
|
49
53
|
.filter((segment) => segment.length > 0);
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
/** A rule file, and its path in the directory it was read from. */
|
|
56
|
+
interface RuleFile {
|
|
57
|
+
readonly id: RuleId;
|
|
58
|
+
readonly leaf: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The rules among `leaves`, the paths in `directory`, whose rules sit under
|
|
63
|
+
* `prefix` one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`. Anything else
|
|
64
|
+
* in a rule's directory, such as a helper its RULE.ts imports, is not a rule.
|
|
65
|
+
* A directory with both files refuses, and so does a Markdown file outside
|
|
66
|
+
* every rule's directory, but a README.md: before rule directories, it was a
|
|
67
|
+
* rule, and the refusal says where it goes now to keep its id.
|
|
68
|
+
*/
|
|
69
|
+
const ruleFilesIn = (
|
|
70
|
+
directory: string,
|
|
71
|
+
leaves: ReadonlyArray<string>,
|
|
72
|
+
prefix: string,
|
|
73
|
+
): Effect.Effect<ReadonlyArray<RuleFile>, ConfigUnavailable> => {
|
|
74
|
+
const files = leaves.flatMap((leaf): ReadonlyArray<RuleFile> => {
|
|
75
|
+
const segments = segmentsOf(leaf.slice(prefix.length));
|
|
76
|
+
return leaf.startsWith(prefix) && segments.length > 1 && RULE_FILES.has(segments.at(-1) ?? "")
|
|
77
|
+
? [{ id: segments.slice(0, -1).join("/"), leaf }]
|
|
78
|
+
: [];
|
|
79
|
+
});
|
|
80
|
+
const both = files.find((file, index) => files.findIndex(({ id }) => id === file.id) < index);
|
|
81
|
+
if (both !== undefined) {
|
|
82
|
+
return Effect.fail(
|
|
83
|
+
ConfigUnavailable.make({
|
|
84
|
+
message: `${directory}/${prefix}${both.id}: a rule is a RULE.md or a RULE.ts, not both`,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const homes = files.map(({ leaf }) => leaf.slice(0, leaf.lastIndexOf("/") + 1));
|
|
89
|
+
const flat = leaves.filter((leaf) => {
|
|
90
|
+
const name = segmentsOf(leaf).at(-1) ?? "";
|
|
91
|
+
return (
|
|
92
|
+
leaf.endsWith(".md") &&
|
|
93
|
+
!RULE_FILES.has(name) &&
|
|
94
|
+
name !== "README.md" &&
|
|
95
|
+
!homes.some((home) => leaf.startsWith(home))
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
if (flat.length > 0) {
|
|
99
|
+
return Effect.fail(
|
|
100
|
+
ConfigUnavailable.make({
|
|
101
|
+
message: [
|
|
102
|
+
`Rules now live one to a directory, as ${directory}/${prefix}<id>/RULE.md or RULE.ts. Move each of these to keep its id:`,
|
|
103
|
+
...flat.map(
|
|
104
|
+
(leaf) =>
|
|
105
|
+
` ${directory}/${leaf} → ${directory}/${prefix}${leaf.slice(0, -".md".length)}/RULE.md`,
|
|
106
|
+
),
|
|
107
|
+
].join("\n"),
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return Effect.succeed(files);
|
|
112
|
+
};
|
|
52
113
|
|
|
53
114
|
/**
|
|
54
115
|
* Whether the rule walk reads into a directory: on the way to a `.adhere/`,
|
|
55
116
|
* not a skipped tree or `.git`; inside one, anything but its cache, since a
|
|
56
|
-
* directory there such as `.adhere/e2e/` is a rule topic.
|
|
117
|
+
* directory there such as `.adhere/rules/e2e/` is a rule topic.
|
|
57
118
|
*/
|
|
58
119
|
const entersForRules = (relative: string): boolean => {
|
|
59
120
|
const segments = segmentsOf(relative);
|
|
@@ -65,45 +126,30 @@ const entersForRules = (relative: string): boolean => {
|
|
|
65
126
|
return name === ADHERE_SEGMENT || !(SKIPPED_DIRECTORIES.has(name) || name === ".git");
|
|
66
127
|
};
|
|
67
128
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return adhere < 0 ? [] : segments.slice(0, adhere);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const ruleIdOf = (relative: string): RuleId => {
|
|
91
|
-
const rulePath = rulePathOf(segmentsOf(relative));
|
|
92
|
-
const leaf = rulePath.join("/");
|
|
93
|
-
return leaf.slice(0, -".md".length);
|
|
129
|
+
/**
|
|
130
|
+
* Each `.adhere/` the walk found, by its path from the root, with the paths
|
|
131
|
+
* of the files in it. Only the path to a `.adhere/` counts against skipped
|
|
132
|
+
* trees: `.adhere/rules/e2e/` is a rule topic, not a skip.
|
|
133
|
+
*/
|
|
134
|
+
const adhereDirectoriesOf = (
|
|
135
|
+
relatives: ReadonlyArray<string>,
|
|
136
|
+
): ReadonlyMap<string, ReadonlyArray<string>> => {
|
|
137
|
+
const found = new Map<string, Array<string>>();
|
|
138
|
+
for (const relative of relatives) {
|
|
139
|
+
const segments = segmentsOf(relative);
|
|
140
|
+
const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
|
|
141
|
+
if (adhere < 0 || segments.slice(0, adhere).some((name) => SKIPPED_DIRECTORIES.has(name))) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const directory = segments.slice(0, adhere + 1).join("/");
|
|
145
|
+
found.set(directory, [...(found.get(directory) ?? []), segments.slice(adhere + 1).join("/")]);
|
|
146
|
+
}
|
|
147
|
+
return found;
|
|
94
148
|
};
|
|
95
149
|
|
|
96
150
|
const joinPath = (path: Path.Path, root: string, parts: ReadonlyArray<string>): string =>
|
|
97
151
|
parts.reduce((current, part) => path.join(current, part), root);
|
|
98
152
|
|
|
99
|
-
const scopedRule = (path: Path.Path, root: string, relative: string, rule: Rule): RuleEntry => {
|
|
100
|
-
const segments = segmentsOf(relative);
|
|
101
|
-
const scopeParts = scopePathOf(segments);
|
|
102
|
-
const scope = scopeParts.length === 0 ? path.resolve(root) : joinPath(path, root, scopeParts);
|
|
103
|
-
const file = joinPath(path, root, segments);
|
|
104
|
-
return { id: ruleIdOf(relative), file, scope, rule };
|
|
105
|
-
};
|
|
106
|
-
|
|
107
153
|
const isInside = (file: string, scope: string): boolean =>
|
|
108
154
|
file === scope || file.startsWith(`${scope}/`);
|
|
109
155
|
|
|
@@ -111,9 +157,40 @@ const moreSpecific = (a: RuleEntry, b: RuleEntry): RuleEntry =>
|
|
|
111
157
|
normalized(a.scope).length >= normalized(b.scope).length ? a : b;
|
|
112
158
|
|
|
113
159
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
160
|
+
* A rule written in TypeScript: the file's default export, decoded as a
|
|
161
|
+
* config's inline rule is, so a hook such as `appendState` comes with it.
|
|
162
|
+
* Like a config, the file is imported, so its code runs.
|
|
163
|
+
*/
|
|
164
|
+
const importRule = Effect.fn("importRule")(function* (file: string) {
|
|
165
|
+
const path = yield* Path.Path;
|
|
166
|
+
const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
|
|
167
|
+
const loaded = yield* Effect.tryPromise({
|
|
168
|
+
try: () => import(url.href) as Promise<{ readonly default?: unknown }>,
|
|
169
|
+
catch: (cause) =>
|
|
170
|
+
ConfigUnavailable.make({
|
|
171
|
+
message: `Could not load ${file}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
return yield* Schema.decodeUnknownEffect(Rule)(loaded.default).pipe(
|
|
175
|
+
Effect.mapError((problem) =>
|
|
176
|
+
ConfigUnavailable.make({
|
|
177
|
+
message: `${file}: a rule file must default-export defineRule({...}): ${problem.message}`,
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/** A rule file's rule. A file that cannot be read refuses with the directory it was read from. */
|
|
184
|
+
const readRule = Effect.fn("readRule")(function* (file: string, directory: string) {
|
|
185
|
+
if (file.endsWith(".ts")) return yield* importRule(file);
|
|
186
|
+
const fs = yield* FileSystem.FileSystem;
|
|
187
|
+
const text = yield* fs.readFileString(file).pipe(Effect.mapError(refused(directory)));
|
|
188
|
+
return yield* parseRuleMarkdown(text, file);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Every rule under `directory`, validated and keyed by its directory's path
|
|
193
|
+
* in it: `basics/gen/RULE.md` is the rule `basics/gen`.
|
|
117
194
|
*/
|
|
118
195
|
export const loadRules = Effect.fn("loadRules")(function* (directory: string) {
|
|
119
196
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -121,19 +198,18 @@ export const loadRules = Effect.fn("loadRules")(function* (directory: string) {
|
|
|
121
198
|
const entries = yield* fs
|
|
122
199
|
.readDirectory(directory, { recursive: true })
|
|
123
200
|
.pipe(Effect.mapError(refused(directory)));
|
|
124
|
-
const files = entries.
|
|
125
|
-
const rules = yield* Effect.forEach(files, (
|
|
126
|
-
Effect.
|
|
127
|
-
const file = path.join(directory, relative);
|
|
128
|
-
const text = yield* fs.readFileString(file).pipe(Effect.mapError(refused(directory)));
|
|
129
|
-
const id: RuleId = relative.slice(0, -".md".length).split(path.sep).join("/");
|
|
130
|
-
return [id, yield* parseRuleMarkdown(text, file)] as const;
|
|
131
|
-
}),
|
|
201
|
+
const files = yield* ruleFilesIn(directory, entries.map(normalized).sort(), "");
|
|
202
|
+
const rules = yield* Effect.forEach(files, ({ id, leaf }) =>
|
|
203
|
+
Effect.map(readRule(path.join(directory, leaf), directory), (rule) => [id, rule] as const),
|
|
132
204
|
);
|
|
133
205
|
return Record.fromEntries(rules) as Readonly<Record<RuleId, Rule>>;
|
|
134
206
|
});
|
|
135
207
|
|
|
136
|
-
/**
|
|
208
|
+
/**
|
|
209
|
+
* Every rule in a `.adhere/rules/` under `root`, scoped to the directory that
|
|
210
|
+
* holds its `.adhere/`: `packages/api/.adhere/rules/data/ports/RULE.md` is
|
|
211
|
+
* `data/ports` for the files under `packages/api/`.
|
|
212
|
+
*/
|
|
137
213
|
export const loadAdhereRuleSet = Effect.fn("loadAdhereRuleSet")(function* (root: string) {
|
|
138
214
|
const fs = yield* FileSystem.FileSystem;
|
|
139
215
|
const path = yield* Path.Path;
|
|
@@ -145,14 +221,17 @@ export const loadAdhereRuleSet = Effect.fn("loadAdhereRuleSet")(function* (root:
|
|
|
145
221
|
),
|
|
146
222
|
),
|
|
147
223
|
).pipe(Effect.mapError(refused(root)));
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
224
|
+
const found = yield* Effect.forEach(adhereDirectoriesOf(entries), ([adhere, leaves]) =>
|
|
225
|
+
Effect.flatMap(ruleFilesIn(adhere, [...leaves].sort(), RULES_PREFIX), (files) => {
|
|
226
|
+
const owner = segmentsOf(adhere).slice(0, -1);
|
|
227
|
+
const scope = owner.length === 0 ? path.resolve(root) : joinPath(path, root, owner);
|
|
228
|
+
return Effect.forEach(files, ({ id, leaf }) => {
|
|
229
|
+
const file = joinPath(path, root, segmentsOf(`${adhere}/${leaf}`));
|
|
230
|
+
return Effect.map(readRule(file, root), (rule): RuleEntry => ({ id, file, scope, rule }));
|
|
231
|
+
});
|
|
154
232
|
}),
|
|
155
233
|
);
|
|
234
|
+
return found.flat();
|
|
156
235
|
});
|
|
157
236
|
|
|
158
237
|
/** Rules as given, or loaded from the directory a source names. */
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ADHERE_DIRECTORY,
|
|
3
2
|
AdhereConfig as AdhereConfigSchema,
|
|
4
3
|
CONFIG_FILES,
|
|
5
4
|
ConfigUnavailable,
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
presetsOf,
|
|
11
10
|
type ResolvedConfig,
|
|
12
11
|
resolveConfig,
|
|
12
|
+
RULES_DIRECTORY,
|
|
13
13
|
} from "#config.ts";
|
|
14
14
|
import { type PresetName, presetOf, presets, wholePresetOf } from "#presets.ts";
|
|
15
15
|
import {
|
|
@@ -94,7 +94,7 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
94
94
|
const hasProjectRules = discoveredEntries.length > 0;
|
|
95
95
|
if (configFile === undefined && !hasProjectRules && !hasPreset) {
|
|
96
96
|
return yield* ConfigUnavailable.make({
|
|
97
|
-
message: `Nothing to audit against: no config file (${CONFIG_FILES.join(", ")}), no ${
|
|
97
|
+
message: `Nothing to audit against: no config file (${CONFIG_FILES.join(", ")}), no rules in ${RULES_DIRECTORY}/, and no --preset. Pass --preset effect to use the built-in rules.`,
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
const projectEntries: RuleSet =
|
|
@@ -46,10 +46,10 @@ export const CredentialsLive = Layer.effect(Credentials)(
|
|
|
46
46
|
const fs = yield* FileSystem.FileSystem;
|
|
47
47
|
const path = yield* Path.Path;
|
|
48
48
|
|
|
49
|
-
const file = Config.
|
|
49
|
+
const file = Config.NonEmptyString("XDG_CONFIG_HOME").pipe(
|
|
50
50
|
Config.orElse(() =>
|
|
51
|
-
Config.
|
|
52
|
-
Config.orElse(() => Config.
|
|
51
|
+
Config.NonEmptyString("HOME").pipe(
|
|
52
|
+
Config.orElse(() => Config.NonEmptyString("USERPROFILE")),
|
|
53
53
|
Config.map((home) => path.join(home, ".config")),
|
|
54
54
|
),
|
|
55
55
|
),
|
|
@@ -78,7 +78,7 @@ export const CredentialsLive = Layer.effect(Credentials)(
|
|
|
78
78
|
|
|
79
79
|
// Read when a request needs the key, not before: a cached run needs none.
|
|
80
80
|
const apiKey = Effect.fn("Credentials.apiKey")(function* () {
|
|
81
|
-
const fromEnvironment = yield* Config.option(Config.
|
|
81
|
+
const fromEnvironment = yield* Config.option(Config.Redacted("TYPESAFE_API_KEY")).pipe(
|
|
82
82
|
Effect.mapError((problem) => refused(`Cannot read TYPESAFE_API_KEY: ${problem.message}`)),
|
|
83
83
|
);
|
|
84
84
|
if (Option.isSome(fromEnvironment)) return fromEnvironment.value;
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuleId } from "#config.ts";
|
|
1
|
+
import type { AppendState, JevState, JudgedFile, RuleId } from "#config.ts";
|
|
2
2
|
import { sectionsOf } from "#excerpt.ts";
|
|
3
3
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
4
4
|
import { Credentials } from "#services/Credentials.ts";
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
namedPairs,
|
|
23
23
|
type Pair,
|
|
24
24
|
pairProbability,
|
|
25
|
+
requestGroups,
|
|
25
26
|
requestsOf,
|
|
26
27
|
type Rules,
|
|
27
28
|
sufficiencyKey,
|
|
@@ -38,9 +39,9 @@ import {
|
|
|
38
39
|
Schedule,
|
|
39
40
|
Schema,
|
|
40
41
|
} from "effect";
|
|
41
|
-
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/
|
|
42
|
-
import * as HttpClientRequest from "effect/
|
|
43
|
-
import { RateLimiter } from "effect/
|
|
42
|
+
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";
|
|
43
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
44
|
+
import { RateLimiter } from "effect/persistence";
|
|
44
45
|
|
|
45
46
|
const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
|
|
46
47
|
|
|
@@ -63,6 +64,35 @@ const LocateAnswers = Schema.Struct({
|
|
|
63
64
|
|
|
64
65
|
const refused = (message: string) => JevUnavailable.make({ message });
|
|
65
66
|
|
|
67
|
+
/** Bun's API, for a rule's hook. Read off `globalThis`, as presets.ts reads it: Vitest runs this on Node. */
|
|
68
|
+
const bun = (globalThis as { readonly Bun?: unknown }).Bun as Parameters<AppendState>[2];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A body as it goes out: when its rule has `appendState`, with what the hook
|
|
72
|
+
* returns spread over the body's state. A hook that fails refuses the run.
|
|
73
|
+
*/
|
|
74
|
+
const hooked = (
|
|
75
|
+
body: Body & { readonly state: JevState },
|
|
76
|
+
group: Rules,
|
|
77
|
+
file: JudgedFile,
|
|
78
|
+
): Effect.Effect<Body, JevUnavailable> => {
|
|
79
|
+
const [hook] = Object.entries(group).flatMap(([id, rule]) =>
|
|
80
|
+
rule.appendState === undefined ? [] : [[id, rule.appendState] as const],
|
|
81
|
+
);
|
|
82
|
+
if (hook === undefined) return Effect.succeed(body);
|
|
83
|
+
const [id, appendState] = hook;
|
|
84
|
+
return Effect.tryPromise({
|
|
85
|
+
try: async () => ({
|
|
86
|
+
...body,
|
|
87
|
+
state: { ...body.state, ...(await appendState(body.state, file, bun)) },
|
|
88
|
+
}),
|
|
89
|
+
catch: (cause) =>
|
|
90
|
+
refused(
|
|
91
|
+
`the appendState of ${id} failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
92
|
+
),
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
66
96
|
/** A failed attempt, for the log: the status and Ray ID when Jev's side answered, the cause otherwise. */
|
|
67
97
|
const attemptFailure = (
|
|
68
98
|
problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
|
|
@@ -224,14 +254,44 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
224
254
|
),
|
|
225
255
|
);
|
|
226
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Every answer about a file's rules: a body for each group that
|
|
259
|
+
* `requestGroups` makes of them, each with its rule's hook run right
|
|
260
|
+
* before it goes out.
|
|
261
|
+
*/
|
|
262
|
+
const answersAbout = <A>(
|
|
263
|
+
kind: string,
|
|
264
|
+
rules: Rules,
|
|
265
|
+
bodyOf: (group: Rules) => Body & { readonly state: JevState },
|
|
266
|
+
file: JudgedFile,
|
|
267
|
+
Answers: Schema.Codec<
|
|
268
|
+
{ readonly answers: Readonly<Record<string, A>> },
|
|
269
|
+
unknown,
|
|
270
|
+
never,
|
|
271
|
+
never
|
|
272
|
+
>,
|
|
273
|
+
) =>
|
|
274
|
+
Effect.map(
|
|
275
|
+
Effect.forEach(requestGroups(rules), (group) =>
|
|
276
|
+
Effect.flatMap(hooked(bodyOf(group), group, file), (body) =>
|
|
277
|
+
answersTo(kind, body, Answers),
|
|
278
|
+
),
|
|
279
|
+
),
|
|
280
|
+
(groups) =>
|
|
281
|
+
groups.reduce<Record<string, A>>((answers, some) => ({ ...answers, ...some }), {}),
|
|
282
|
+
);
|
|
283
|
+
|
|
227
284
|
const judge = Effect.fn("Jev.judge")(function* (
|
|
228
285
|
lines: Lines,
|
|
229
286
|
rules: Rules,
|
|
230
|
-
sampled: ReadonlyArray<RuleId
|
|
287
|
+
sampled: ReadonlyArray<RuleId>,
|
|
288
|
+
file: JudgedFile,
|
|
231
289
|
) {
|
|
232
|
-
const answers = yield*
|
|
290
|
+
const answers = yield* answersAbout(
|
|
233
291
|
"judge",
|
|
234
|
-
|
|
292
|
+
rules,
|
|
293
|
+
(group) => judgeBody(config.model, lines, group, sampled),
|
|
294
|
+
file,
|
|
235
295
|
NoulAnswers,
|
|
236
296
|
);
|
|
237
297
|
/** Each rule's answer, read from the question it rode under. */
|
|
@@ -270,11 +330,17 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
270
330
|
} satisfies Judged;
|
|
271
331
|
});
|
|
272
332
|
|
|
273
|
-
const locate = Effect.fn("Jev.locate")(function* (
|
|
333
|
+
const locate = Effect.fn("Jev.locate")(function* (
|
|
334
|
+
lines: Lines,
|
|
335
|
+
rules: Rules,
|
|
336
|
+
file: JudgedFile,
|
|
337
|
+
) {
|
|
274
338
|
const sections = sectionsOf(lines);
|
|
275
|
-
const answers = yield*
|
|
339
|
+
const answers = yield* answersAbout(
|
|
276
340
|
"locate",
|
|
277
|
-
|
|
341
|
+
rules,
|
|
342
|
+
(group) => locateBody(config.model, lines, group),
|
|
343
|
+
file,
|
|
278
344
|
LocateAnswers,
|
|
279
345
|
);
|
|
280
346
|
// A file of one section has every finding in it. An answer that names no section,
|
package/src/services/Jev.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type Example,
|
|
3
|
+
examplesOf,
|
|
4
|
+
type JudgedFile,
|
|
5
|
+
type Rule,
|
|
6
|
+
type RuleId,
|
|
7
|
+
type Rules,
|
|
8
|
+
} from "#config.ts";
|
|
2
9
|
import { type Range, sectionsOf } from "#excerpt.ts";
|
|
3
10
|
import { Context, type Effect, Record, Schema } from "effect";
|
|
4
11
|
|
|
@@ -47,12 +54,13 @@ export class Jev extends Context.Service<
|
|
|
47
54
|
/**
|
|
48
55
|
* Each rule's probability that the file breaks it. A rule in `sampled`
|
|
49
56
|
* also carries the linter check's question, whose answer comes back
|
|
50
|
-
* under `linter`.
|
|
57
|
+
* under `linter`. `file` is what a rule's `appendState` gets.
|
|
51
58
|
*/
|
|
52
59
|
readonly judge: (
|
|
53
60
|
lines: Lines,
|
|
54
61
|
rules: Rules,
|
|
55
|
-
sampled
|
|
62
|
+
sampled: ReadonlyArray<RuleId>,
|
|
63
|
+
file: JudgedFile,
|
|
56
64
|
) => Effect.Effect<Judged, JevUnavailable | JevOverflow | JevBlocked>;
|
|
57
65
|
/**
|
|
58
66
|
* Per rule, the section of the file that most clearly breaks it, and
|
|
@@ -62,6 +70,7 @@ export class Jev extends Context.Service<
|
|
|
62
70
|
readonly locate: (
|
|
63
71
|
lines: Lines,
|
|
64
72
|
rules: Rules,
|
|
73
|
+
file: JudgedFile,
|
|
65
74
|
) => Effect.Effect<Record<RuleId, Located>, JevUnavailable | JevOverflow | JevBlocked>;
|
|
66
75
|
/**
|
|
67
76
|
* Per rule, the partner Jev names as impossible to follow in the same code,
|
|
@@ -468,33 +477,53 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
|
|
|
468
477
|
return requests.map(({ questions }) => ({ ...body, questions }));
|
|
469
478
|
};
|
|
470
479
|
|
|
480
|
+
/**
|
|
481
|
+
* The rules as requests carry them: every rule without `appendState` in one
|
|
482
|
+
* body, and each rule with it in a body of its own, so that what its hook
|
|
483
|
+
* puts in the state only its own questions read.
|
|
484
|
+
*/
|
|
485
|
+
export const requestGroups = (rules: Rules): ReadonlyArray<Rules> => {
|
|
486
|
+
const shared = Record.filter(rules, (rule) => rule.appendState === undefined);
|
|
487
|
+
return [
|
|
488
|
+
...(Record.isEmptyRecord(shared) ? [] : [shared]),
|
|
489
|
+
...Object.entries(rules).flatMap(([id, rule]) =>
|
|
490
|
+
rule.appendState === undefined ? [] : [{ [id]: rule }],
|
|
491
|
+
),
|
|
492
|
+
];
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/** The judge requests for `rules` over a file: a body per group, split to fit, as `judge` sends them. */
|
|
496
|
+
const judgeRequestsOf = (lines: Lines, rules: Rules, sampled: ReadonlyArray<RuleId>) =>
|
|
497
|
+
requestGroups(rules).flatMap((group) => requestsOf(judgeBody("", lines, group, sampled)));
|
|
498
|
+
|
|
471
499
|
/**
|
|
472
500
|
* The requests judging `rules` over a file takes, and about how many input
|
|
473
|
-
* tokens they carry, by `tokensOf`: the judge
|
|
474
|
-
* sends
|
|
501
|
+
* tokens they carry, by `tokensOf`: the judge bodies, split to fit, as
|
|
502
|
+
* `judge` sends them, each request with the file again. What a rule's
|
|
503
|
+
* `appendState` adds is not known before it runs, so it is not counted.
|
|
475
504
|
*/
|
|
476
505
|
export const judgeLoad = (
|
|
477
506
|
lines: Lines,
|
|
478
507
|
rules: Rules,
|
|
479
508
|
sampled: ReadonlyArray<RuleId> = [],
|
|
480
509
|
): { readonly requests: number; readonly tokens: number } => {
|
|
481
|
-
const requests =
|
|
510
|
+
const requests = judgeRequestsOf(lines, rules, sampled);
|
|
482
511
|
return {
|
|
483
512
|
requests: requests.length,
|
|
484
513
|
tokens: requests.reduce((sum, request) => sum + tokensOf(request), 0),
|
|
485
514
|
};
|
|
486
515
|
};
|
|
487
516
|
|
|
488
|
-
/** The requests judging `rules` over a file takes: the judge
|
|
517
|
+
/** The requests judging `rules` over a file takes: the judge bodies, split to fit, as `judge` sends them. */
|
|
489
518
|
export const judgeRequests = (
|
|
490
519
|
lines: Lines,
|
|
491
520
|
rules: Rules,
|
|
492
521
|
sampled: ReadonlyArray<RuleId> = [],
|
|
493
|
-
): number =>
|
|
522
|
+
): number => judgeRequestsOf(lines, rules, sampled).length;
|
|
494
523
|
|
|
495
|
-
/** The requests locating `rules` in a file takes, split to fit as `locate` sends them. */
|
|
524
|
+
/** The requests locating `rules` in a file takes, a body per group split to fit, as `locate` sends them. */
|
|
496
525
|
export const locateRequests = (lines: Lines, rules: Rules): number =>
|
|
497
|
-
requestsOf(locateBody("", lines,
|
|
526
|
+
requestGroups(rules).flatMap((group) => requestsOf(locateBody("", lines, group))).length;
|
|
498
527
|
|
|
499
528
|
/** A choice keeps one criterion for "none", which leaves this many for partners. */
|
|
500
529
|
const PARTNERS_PER_QUESTION = CHOICE_LIMIT - 1;
|
package/src/workflows/audit.ts
CHANGED
|
@@ -41,12 +41,14 @@ export interface Finding {
|
|
|
41
41
|
/** The section's code as written, comments and all, without blank lines at either end. */
|
|
42
42
|
readonly excerpt: Excerpt;
|
|
43
43
|
/**
|
|
44
|
-
* Jev's probability that the file
|
|
45
|
-
*
|
|
46
|
-
* the report warns, and says how to add the context.
|
|
44
|
+
* Jev's probability that the file shows enough to check the rule. Below the
|
|
45
|
+
* sufficiency threshold, the report warns, and says how to add the context.
|
|
47
46
|
*/
|
|
48
|
-
readonly
|
|
47
|
+
readonly context?: number;
|
|
48
|
+
/** Jev's probability that the file breaks the rule. */
|
|
49
49
|
readonly probability: number;
|
|
50
|
+
/** The rule's threshold: lint reports the finding only when its probability is above it. */
|
|
51
|
+
readonly threshold: number;
|
|
50
52
|
/** An error fails the run; a warning does not, unless `--deny-warnings`. */
|
|
51
53
|
readonly level: Level;
|
|
52
54
|
}
|
|
@@ -109,12 +111,16 @@ const LAYOUT = "sections";
|
|
|
109
111
|
* laid out, and the questions asked for the rule, which carry the rule: its
|
|
110
112
|
* judge question, and its matchers' when it has any. An edited rule or
|
|
111
113
|
* matcher re-judges the rule, and a question asked differently re-judges
|
|
112
|
-
* every rule.
|
|
114
|
+
* every rule. So does an edited `appendState`, by its source; what the hook
|
|
115
|
+
* reads outside the file is not known until it runs, so a change there does not.
|
|
113
116
|
*/
|
|
114
117
|
const fingerprintOf = (model: string, rule: Rule) => {
|
|
115
118
|
const matchers = matcherQuestions("", rule).map(([, question]) => question);
|
|
116
119
|
const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
|
|
117
|
-
|
|
120
|
+
const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
|
|
121
|
+
return sha256(
|
|
122
|
+
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
|
|
123
|
+
);
|
|
118
124
|
};
|
|
119
125
|
|
|
120
126
|
/** A section's lines as written, without the blank lines at either end. */
|
|
@@ -192,6 +198,8 @@ export interface AuditPlan {
|
|
|
192
198
|
readonly tokens: number;
|
|
193
199
|
/** The model's price per million input tokens, in dollars, when adhere knows it. */
|
|
194
200
|
readonly price?: number;
|
|
201
|
+
/** The run's threshold, which a rule's own, or an override's, replaces for that rule. */
|
|
202
|
+
readonly threshold: number;
|
|
195
203
|
/** Below this, a finding's sufficiency adds a warning to it. */
|
|
196
204
|
readonly sufficiencyThreshold: number;
|
|
197
205
|
}
|
|
@@ -459,6 +467,7 @@ export const planAudit = (
|
|
|
459
467
|
requests: loads.reduce((sum, load) => sum + load.requests, 0),
|
|
460
468
|
tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
|
|
461
469
|
...(price === undefined ? {} : { price }),
|
|
470
|
+
threshold: config.threshold,
|
|
462
471
|
sufficiencyThreshold: config.sufficiencyThreshold,
|
|
463
472
|
};
|
|
464
473
|
});
|
|
@@ -509,9 +518,13 @@ export const executeAudit = (
|
|
|
509
518
|
deferred,
|
|
510
519
|
sampled,
|
|
511
520
|
}: FilePlan) {
|
|
521
|
+
// What a rule's appendState gets: the file as written, not as Jev reads it.
|
|
522
|
+
const asWritten = { path: file.path, contents: original.join("\n") };
|
|
512
523
|
const { probabilities, linter, matchers }: Judged = isEmpty(pending)
|
|
513
524
|
? { probabilities: {}, linter: {} }
|
|
514
|
-
: yield* jev
|
|
525
|
+
: yield* jev
|
|
526
|
+
.judge(file.lines, rulesOf(pending), Object.keys(sampled), asWritten)
|
|
527
|
+
.pipe(inFile(file));
|
|
515
528
|
if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
|
|
516
529
|
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
517
530
|
for (const [id, probability] of Object.entries(probabilities)) {
|
|
@@ -549,7 +562,7 @@ export const executeAudit = (
|
|
|
549
562
|
readonly blocked: Blocked | undefined;
|
|
550
563
|
} = isEmpty(flagged)
|
|
551
564
|
? { places: {}, blocked: undefined }
|
|
552
|
-
: yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
|
|
565
|
+
: yield* jev.locate(file.lines, rulesOf(flagged), asWritten).pipe(
|
|
553
566
|
Effect.map((places) => ({ places, blocked: undefined })),
|
|
554
567
|
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
555
568
|
Effect.succeed({ places: {}, blocked: { file: file.path, ray } }),
|
|
@@ -581,11 +594,9 @@ export const executeAudit = (
|
|
|
581
594
|
section: judgment.section,
|
|
582
595
|
...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
|
|
583
596
|
excerpt: excerptOf(original, judgment.section),
|
|
584
|
-
...(judgment.sufficiency
|
|
585
|
-
judgment.sufficiency < plan.sufficiencyThreshold
|
|
586
|
-
? { insufficiency: 1 - judgment.sufficiency }
|
|
587
|
-
: {}),
|
|
597
|
+
...(judgment.sufficiency === undefined ? {} : { context: judgment.sufficiency }),
|
|
588
598
|
probability: judgment.probability,
|
|
599
|
+
threshold: k.threshold,
|
|
589
600
|
level: k.rule.level ?? "error",
|
|
590
601
|
},
|
|
591
602
|
]
|